Compare commits

..
Author SHA1 Message Date
gionnibgud ae6534b603 Route the rigs loader through the shared path helper
#1039 collapsed seven copies of the manifest-path containment guard into
`_resolve_pack_path`, and #1040 added an eighth loader carrying its own
copy. Both were correct against the base they were written on and both
merged in the right order, but #1040 went in without the rebase that
would have joined them — so `_load_rigs_file` is now the one loader in
this file still open-coding the guard.

Route it through the helper like its seven siblings. Same behaviour,
same rendered log message ("sloppak: rigs path %r escapes source_dir —
skipped"), verified by triggering a traversal against the new path.
Full suite unchanged at 2796 passed / 4 skipped.

This is the follow-up promised in #1040's description.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-23 11:58:02 +02:00
72 changed files with 17367 additions and 26659 deletions
-128
View File
@@ -1,128 +0,0 @@
{
"id": "vocals",
"name": "Vocals",
"icon": "vocals",
"order": 5,
"levels": [
{
"level": 1,
"required": 2,
"challenges": [
{
"id": "vocals.l1.first-phrase",
"title": "First Phrase",
"description": "Finish any vocal song with pitch detection on.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 1 }
},
{
"id": "vocals.l1.clean-run",
"title": "Clean Run",
"description": "Score 80%+ accuracy on a vocal song.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.8, "target": 1 }
},
{
"id": "vocals.l1.daily-grind",
"title": "Daily Grind",
"description": "Complete 3 daily quests.",
"goal": { "type": "quest_completed", "period": "daily", "target": 3 }
}
]
},
{
"level": 2,
"required": 2,
"challenges": [
{
"id": "vocals.l2.five-songs",
"title": "Warming Up",
"description": "Finish 5 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 5 }
},
{
"id": "vocals.l2.sharpshooter",
"title": "On Pitch",
"description": "Score 90%+ accuracy on a vocal song.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.9, "target": 1 }
},
{
"id": "vocals.l2.arcade-debut",
"title": "Arcade Debut",
"description": "Play 3 FeedBarcade rounds.",
"goal": { "type": "minigame_run", "target": 3 }
}
]
},
{
"level": 3,
"required": 2,
"challenges": [
{
"id": "vocals.l3.repertoire",
"title": "Repertoire",
"description": "Finish 10 different vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "distinct": true, "target": 10 }
},
{
"id": "vocals.l3.consistent",
"title": "Consistent",
"description": "Score 85%+ accuracy on 5 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.85, "target": 5 }
},
{
"id": "vocals.l3.weekly-warrior",
"title": "Weekly Warrior",
"description": "Complete 2 weekly quests.",
"goal": { "type": "quest_completed", "period": "weekly", "target": 2 }
}
]
},
{
"level": 4,
"required": 2,
"challenges": [
{
"id": "vocals.l4.streak-week",
"title": "Seven-Day Streak",
"description": "Reach a 7-day play streak.",
"goal": { "type": "streak_reached", "days": 7 }
},
{
"id": "vocals.l4.precision",
"title": "Pitch Perfect",
"description": "Score 95%+ accuracy on 3 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "target": 3 }
},
{
"id": "vocals.l4.marathon",
"title": "Marathon",
"description": "Finish 25 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 25 }
}
]
},
{
"level": 5,
"required": 2,
"challenges": [
{
"id": "vocals.l5.collector",
"title": "Collector",
"description": "Earn 5,000 lifetime Decibels.",
"goal": { "type": "db_earned", "amount": 5000 }
},
{
"id": "vocals.l5.virtuoso",
"title": "Virtuoso",
"description": "Score 95%+ accuracy on 10 different vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "min_accuracy": 0.95, "distinct": true, "target": 10 }
},
{
"id": "vocals.l5.dedicated",
"title": "Dedicated",
"description": "Finish 50 vocal songs.",
"goal": { "type": "song_completed", "instrument": "vocals", "target": 50 }
}
]
}
]
}
-17
View File
@@ -67,23 +67,6 @@ module.exports = [
'import-x/no-cycle': 'error', 'import-x/no-cycle': 'error',
}, },
}, },
// highway_3d plugin — screen.js uses ES-module syntax (scriptType:module)
// so it needs module sourceType to parse. Add no-use-before-define here
// (variables: true, functions: false) to catch const/let TDZ violations
// inside factory functions across the whole plugin tree.
// Proven to flag the broken-tip regression (fix/h3d-viz-init-fallback):
// broken: const sY used at createScoreFx DI before its declaration → ERROR
// fixed: sY hoisted above createScoreFx call → clean (0 errors)
{
files: [
'plugins/highway_3d/screen.js',
'plugins/highway_3d/src/**/*.js',
],
languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
rules: {
'no-use-before-define': ['error', { variables: true, functions: false }],
},
},
// Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so // Signed size exemptions (docs/size-exemptions.md) — raise the ceiling so
// registered files don't warn below it. // registered files don't warn below it.
...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })), ...SIZE_EXEMPTIONS.map(({ files, max }) => ({ files, rules: { 'max-lines': sizeRule(max) } })),
+7 -68
View File
@@ -171,13 +171,6 @@ _SCAN_STATUS_INIT = {"running": False, "stage": "idle", "total": 0, "done": 0, "
_scan_status = dict(_SCAN_STATUS_INIT) _scan_status = dict(_SCAN_STATUS_INIT)
# Mass-prune guard thresholds for automatic scans (not full rescan).
# Refuse when would_remove >= max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * existing).
# At 50 % of the library (or even a single row on tiny libraries) a sudden
# disappearance almost certainly means a degraded mount, not a real deletion.
_PRUNE_MAX_ABS = 1
_PRUNE_MAX_FRAC = 0.5
def _make_scan_executor(): def _make_scan_executor():
"""Build the executor for the background metadata scan. """Build the executor for the background metadata scan.
@@ -220,17 +213,12 @@ def _make_scan_executor():
) )
def background_scan(force: bool = False, allow_mass_prune: bool = False): def background_scan(force: bool = False):
"""Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing. """Scan the library and cache song metadata on startup. Uses a process pool to bypass the GIL for CPU-bound metadata parsing.
`force` skips the directory-signature fast path and always does the full `force` skips the directory-signature fast path and always does the full
listing/stat pass — the manual Refresh sets it (see _dir_signature_file). listing/stat pass — the manual Refresh sets it (see _dir_signature_file).
`allow_mass_prune` permits the scan to prune more than the catastrophic
threshold (_PRUNE_MAX_FRAC of the library). Only set by /api/rescan/full
(explicit user intent); automatic and plain /api/rescan scans leave it
False so a degraded-mount partial listing can't silently wipe the library.
Never sets `_scan_status["running"] = False` — ownership of that flag Never sets `_scan_status["running"] = False` — ownership of that flag
lives in `_scan_runner` so a `kick_scan()` racing this function's lives in `_scan_runner` so a `kick_scan()` racing this function's
terminal write cannot observe a stale False and start a second runner. terminal write cannot observe a stale False and start a second runner.
@@ -325,43 +313,6 @@ def background_scan(force: bool = False, allow_mass_prune: bool = False):
current_files = {_relpath(f, dlc) for f in all_songs} current_files = {_relpath(f, dlc) for f in all_songs}
# Guard: refuse (or warn) when the listing suggests a degraded mount.
# Two cases share the same logic:
# 1. Zero listing — current_files empty, DB non-empty: would erase everything.
# 2. Partial listing — current_files non-empty but so many DB rows are absent
# that it looks like a mount glitch rather than deliberate deletions.
# Threshold: would_remove >= max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * existing).
# allow_mass_prune (only True for /api/rescan/full) lets the prune proceed with
# a warning so the user's explicit intent is honoured even in the degraded case.
with appstate.meta_db._lock:
_existing = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
if _existing > 0:
if not current_files:
_would_remove = _existing
else:
with appstate.meta_db._lock:
_db_files = {r[0] for r in appstate.meta_db.conn.execute(
"SELECT filename FROM songs").fetchall()}
_would_remove = len(_db_files - current_files)
_threshold = max(_PRUNE_MAX_ABS, _PRUNE_MAX_FRAC * _existing)
if _would_remove >= _threshold:
_msg = (
f"Scan: would remove {_would_remove} of {_existing} DB rows "
f"(threshold {int(_threshold)}) with only {len(current_files)} song(s) visible "
"— possible mount/permission issue. Check the DLC mount; use Settings → "
"Rescan Library (full) to authorise a large prune."
)
if allow_mass_prune:
log.warning("%s — proceeding (user-authorised full rescan)", _msg)
else:
log.error("%s", _msg)
_scan_status = {**_SCAN_STATUS_INIT, "running": True,
"stage": "error", "error": _msg}
return
# Clean up stale DB entries. delete_missing reports both deltas (rows pruned # Clean up stale DB entries. delete_missing reports both deltas (rows pruned
# + genuinely-new files) so the scan can surface an added/removed summary. # + genuinely-new files) so the scan can surface an added/removed summary.
_delta = appstate.meta_db.delete_missing(current_files) _delta = appstate.meta_db.delete_missing(current_files)
@@ -455,10 +406,6 @@ _scan_rescan_pending = False
# Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a # Set by kick_scan(force=True); consumed by _scan_runner for the next pass so a
# manual Refresh bypasses the directory-signature fast path. # manual Refresh bypasses the directory-signature fast path.
_scan_force_next = False _scan_force_next = False
# Set by kick_scan(allow_mass_prune=True); allows the next pass to prune past the
# catastrophic threshold. Sticky like _scan_force_next: if any queued request asks
# for it, the follow-up pass honours it.
_scan_mass_prune_next = False
# Handles to the running scan / enrichment worker threads. Both use the shared # Handles to the running scan / enrichment worker threads. Both use the shared
@@ -469,7 +416,7 @@ _scan_mass_prune_next = False
_scan_thread: threading.Thread | None = None _scan_thread: threading.Thread | None = None
def kick_scan(force: bool = False, allow_mass_prune: bool = False) -> bool: def kick_scan(force: bool = False) -> bool:
"""Request a library rescan, single-flight + coalescing. """Request a library rescan, single-flight + coalescing.
`force` skips the directory-signature fast path for the resulting pass (the `force` skips the directory-signature fast path for the resulting pass (the
@@ -478,10 +425,6 @@ def kick_scan(force: bool = False, allow_mass_prune: bool = False) -> bool:
onto a running or queued scan keeps the force intent: the pass is forced if onto a running or queued scan keeps the force intent: the pass is forced if
ANY pending request asked for it. ANY pending request asked for it.
`allow_mass_prune` permits the resulting pass to prune past the catastrophic
threshold. Sticky: if any pending request set it, the follow-up pass honours it.
Only /api/rescan/full passes True — plain rescans and startup scans never do.
Returns True if a new scan thread was started, False if one was already Returns True if a new scan thread was started, False if one was already
running. In the latter case a follow-up pass is queued and runs as soon running. In the latter case a follow-up pass is queued and runs as soon
as the current scan finishes so files landing mid-scan (e.g. an upload as the current scan finishes so files landing mid-scan (e.g. an upload
@@ -489,12 +432,10 @@ def kick_scan(force: bool = False, allow_mass_prune: bool = False) -> bool:
until the next periodic pass. Multiple late-arriving requests coalesce until the next periodic pass. Multiple late-arriving requests coalesce
into a single follow-up. into a single follow-up.
""" """
global _scan_rescan_pending, _scan_thread, _scan_force_next, _scan_mass_prune_next global _scan_rescan_pending, _scan_thread, _scan_force_next
with _scan_kick_lock: with _scan_kick_lock:
if force: if force:
_scan_force_next = True _scan_force_next = True
if allow_mass_prune:
_scan_mass_prune_next = True
if _scan_status["running"]: if _scan_status["running"]:
_scan_rescan_pending = True _scan_rescan_pending = True
return False return False
@@ -508,17 +449,15 @@ def kick_scan(force: bool = False, allow_mass_prune: bool = False) -> bool:
def _scan_runner(): def _scan_runner():
"""Run _background_scan, then re-run if requests arrived mid-scan.""" """Run _background_scan, then re-run if requests arrived mid-scan."""
global _scan_rescan_pending, _scan_force_next, _scan_mass_prune_next global _scan_rescan_pending, _scan_force_next
while True: while True:
# Consume both flags for THIS pass; requests queued mid-scan set them # Consume the force flag for THIS pass; a forced request queued mid-scan
# again for the follow-up (sticky: any requester who asked for it wins). # sets it again for the follow-up.
with _scan_kick_lock: with _scan_kick_lock:
forced = _scan_force_next forced = _scan_force_next
_scan_force_next = False _scan_force_next = False
mass_prune = _scan_mass_prune_next
_scan_mass_prune_next = False
try: try:
background_scan(force=forced, allow_mass_prune=mass_prune) background_scan(force=forced)
except Exception: except Exception:
log.exception("background scan failed unexpectedly") log.exception("background scan failed unexpectedly")
+2 -10
View File
@@ -816,16 +816,8 @@ def _load_rigs_file(source_dir: Path, rel: str) -> dict | None:
realization selection and the `intent.gm` fallback belong to whatever realization selection and the `intent.gm` fallback belong to whatever
voices the part. voices the part.
""" """
try: r_path = _resolve_pack_path(source_dir, rel, "rigs")
r_path = (source_dir / rel).resolve() if r_path is None or not r_path.exists():
r_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: rigs path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: rigs path resolution failed (%s) — skipped", e)
return None
if not r_path.exists():
return None return None
try: try:
raw = load_json(r_path) raw = load_json(r_path)
-12
View File
@@ -789,15 +789,3 @@
} }
.pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; } .pp-giglog-row { font-size: 0.7rem; color: #6d5d40; padding: 0.1rem 0; }
.pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; } .pp-giglog-row b { color: #9a5b16; letter-spacing: 0.06em; }
/* Tuning preference pills on gig poster */
.pp-tuning-row { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; margin: 0.5rem 0 0.3rem; }
.pp-tuning-pill { font-size: 0.65rem; padding: 0.2rem 0.6rem; border-radius: 999px; opacity: 0.7; }
.pp-tuning-pill-on { opacity: 1; border-color: #d9a253; color: #d9a253; }
.pp-tuning-select { font-size: 0.7rem; background: #1a1510; color: #c8b48a; border: 1px solid rgba(138,122,94,0.5); border-radius: 4px; padding: 0.15rem 0.4rem; }
.pp-poster-tuning-chip { font-size: 0.6rem; color: #8a7a5e; margin-left: 0.35rem; }
/* Gig interstitial — tune up banner */
.pp-gig-strip.pp-interstitial { pointer-events: auto; display: flex; align-items: center; gap: 0.75rem; border-radius: 8px; border-color: rgba(64,128,224,0.5); }
.pp-gig-tune-label { color: #4080e0; letter-spacing: 0.08em; font-size: 0.78rem; }
.pp-gig-start-btn { font-size: 0.7rem; padding: 0.25rem 0.7rem; }
+7 -74
View File
@@ -529,7 +529,7 @@ def _current_venue():
return best return best
def _fill_genre_songs(gkey, exclude, limit, tuning_ok=None): def _fill_genre_songs(gkey, exclude, limit):
"""Library songs of a genre to round out a gig — ANY song of the genre the """Library songs of a genre to round out a gig — ANY song of the genre the
set hasn't already picked. set hasn't already picked.
@@ -553,41 +553,17 @@ def _fill_genre_songs(gkey, exclude, limit, tuning_ok=None):
if db is None: if db is None:
return [] return []
rows = db.conn.execute( rows = db.conn.execute(
f"SELECT filename, title, artist, {_genre_expr(db)} AS g, tuning_name FROM songs" f"SELECT filename, title, artist, {_genre_expr(db)} AS g FROM songs"
).fetchall() ).fetchall()
pool = [ pool = [
{"filename": fn, "title": title or fn, "artist": artist or "", "tuning_name": tn or ""} {"filename": filename, "title": title or filename, "artist": artist or ""}
for fn, title, artist, genre, tn in rows for filename, title, artist, genre in rows
if _genre_key(genre) == gkey and fn not in exclude if _genre_key(genre) == gkey and filename not in exclude
and (tuning_ok is None or tuning_ok(tn or ""))
] ]
random.shuffle(pool) # re-roll must vary; free per call random.shuffle(pool) # re-roll must vary; free per call
return pool[:limit] return pool[:limit]
def _tuning_ok_fn(tuning_pref):
"""Return a (tuning_name: str) -> bool callable for the given preference.
Returns None for 'any' (no filter). 'standard' matches names ending in
' Standard'; 'drop' matches names containing 'Drop' (covers Drop D, Drop C,
Double Drop D, etc.); 'specific:<name>' matches exact names. Unknown or
malformed prefs treat as 'any' rather than hard-failing — a stale client
request must not break gig booking.
"""
if not tuning_pref or tuning_pref == "any":
return None
if tuning_pref == "standard":
return lambda n: bool(n) and n.endswith(" Standard")
if tuning_pref == "drop":
return lambda n: bool(n) and "Drop" in n
if tuning_pref.startswith("specific:"):
spec = tuning_pref[len("specific:"):]
if not spec or len(spec) > 64:
return None
return lambda n, _s=spec: n == _s
return None # unknown pref → no filter
def _validate_pack_dir(pack_dir: Path): def _validate_pack_dir(pack_dir: Path):
"""Raise ValueError unless pack_dir holds a complete venue pack.""" """Raise ValueError unless pack_dir holds a complete venue pack."""
manifest_path = pack_dir / "manifest.json" manifest_path = pack_dir / "manifest.json"
@@ -847,8 +823,6 @@ def setup(app, context):
raise HTTPException(400, "Unknown instrument.") raise HTTPException(400, "Unknown instrument.")
if not gkey or len(genre) > GENRE_MAX_LEN: if not gkey or len(genre) > GENRE_MAX_LEN:
raise HTTPException(400, "Provide a genre.") raise HTTPException(400, "Provide a genre.")
tuning_pref = str((body or {}).get("tuning_pref") or "any")
tuning_ok = _tuning_ok_fn(tuning_pref)
cfg = _gig_config() cfg = _gig_config()
try: try:
size = int((body or {}).get("size") or 4) size = int((body or {}).get("size") or 4)
@@ -857,18 +831,6 @@ def setup(app, context):
size = max(cfg["min_songs"], min(cfg["max_songs"], size)) size = max(cfg["min_songs"], min(cfg["max_songs"], size))
played, _seconds = _played_by_instrument_genre() played, _seconds = _played_by_instrument_genre()
stubs = list(played.get((inst, gkey), {}).values()) stubs = list(played.get((inst, gkey), {}).values())
# Annotate stubs with tuning_name (batch lookup to avoid N+1).
if stubs and _state["meta_db"] is not None:
fns = [s["filename"] for s in stubs]
ph = ",".join("?" * len(fns))
tn_rows = _state["meta_db"].conn.execute(
f"SELECT filename, tuning_name FROM songs WHERE filename IN ({ph})", fns
).fetchall()
tn_by_file = {fn: (tn or "") for fn, tn in tn_rows}
for s in stubs:
s.setdefault("tuning_name", tn_by_file.get(s["filename"], ""))
if tuning_ok is not None:
stubs = [s for s in stubs if tuning_ok(s.get("tuning_name", ""))]
req = _badge_requirement(gkey, inst) req = _badge_requirement(gkey, inst)
qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]] qualifying = [s for s in stubs if s["stars"] >= req["min_stars"]]
rest = [s for s in stubs if s["stars"] < req["min_stars"]] rest = [s for s in stubs if s["stars"] < req["min_stars"]]
@@ -893,26 +855,18 @@ def setup(app, context):
picks.append(s) picks.append(s)
if len(picks) < size: if len(picks) < size:
exclude = {s["filename"] for s in picks} exclude = {s["filename"] for s in picks}
picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks), tuning_ok)) picks.extend(_fill_genre_songs(gkey, exclude, size - len(picks)))
if not picks: if not picks:
if tuning_pref and tuning_pref != "any":
label = ("standard-tuning " if tuning_pref == "standard"
else "drop-tuning " if tuning_pref == "drop"
else f"{tuning_pref[len('specific:'):]}”-tuning "
if tuning_pref.startswith("specific:") else "")
raise HTTPException(404, f"No {label}songs of this genre in the library.")
raise HTTPException(404, "No songs of this genre in the library.") raise HTTPException(404, "No songs of this genre in the library.")
venue = _current_venue() venue = _current_venue()
return { return {
"instrument": inst, "instrument": inst,
"genre": genre, "genre": genre,
"genre_key": gkey, "genre_key": gkey,
"tuning_pref": tuning_pref,
"venue_id": venue["id"] if venue else None, "venue_id": venue["id"] if venue else None,
"venue_name": venue["name"] if venue else "", "venue_name": venue["name"] if venue else "",
"songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"], "songs": [{"filename": s["filename"], "title": s.get("title") or s["filename"],
"artist": s.get("artist") or "", "tuning_name": s.get("tuning_name") or ""} "artist": s.get("artist") or ""} for s in picks[:size]],
for s in picks[:size]],
} }
@app.post(f"/api/plugins/{PLUGIN_ID}/gigs") @app.post(f"/api/plugins/{PLUGIN_ID}/gigs")
@@ -981,27 +935,6 @@ def setup(app, context):
_save_json(_state_file(), st) _save_json(_state_file(), st)
return {"ok": True, "gig": gig} return {"ok": True, "gig": gig}
@app.get(f"/api/plugins/{PLUGIN_ID}/gigs/tunings")
def gig_tunings(genre: str = ""):
"""Distinct tuning names present in a genre's song pool, for the
specific-tuning picker in the gig poster UI. Sorted by the library's
own tuning_sort_key so the list matches the main library tuning filter."""
gkey = _genre_key(_genre_display(genre)) if genre else ""
if not gkey:
return {"tunings": []}
db = _state["meta_db"]
if db is None:
return {"tunings": []}
rows = db.conn.execute(
f"SELECT tuning_name, tuning_sort_key, {_genre_expr(db)} AS g "
"FROM songs WHERE tuning_name != ''"
).fetchall()
seen: dict[str, int] = {}
for tn, sk, genre_raw in rows:
if _genre_key(genre_raw) == gkey and tn and tn not in seen:
seen[tn] = sk or 0
return {"tunings": sorted(seen.keys(), key=lambda n: (seen[n], n))}
@app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download") @app.post(f"/api/plugins/{PLUGIN_ID}/packs/{{venue_id}}/download")
def start_download(venue_id: str): def start_download(venue_id: str):
venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None venue = _venue(venue_id) if VENUE_ID_RE.fullmatch(venue_id) else None
+7 -149
View File
@@ -26,7 +26,6 @@
const PP_SEEN_KEY = 'feedBack-career-badges-seen'; const PP_SEEN_KEY = 'feedBack-career-badges-seen';
const PP_INST_KEY = 'feedBack-career-instrument'; const PP_INST_KEY = 'feedBack-career-instrument';
const PP_TAB_KEY = 'feedBack-career-tab'; const PP_TAB_KEY = 'feedBack-career-tab';
const PP_TUNING_PREF_KEY = 'feedBack-career-tuning-pref';
const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' }; const PP_LABELS = { guitar: 'Guitar', bass: 'Bass', keys: 'Keys', drums: 'Drums' };
const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕']; const PP_BROCHURE_ART = ['🎸', '🎷', '🎹', '🥁', '🎺', '🎻', '🎤', '🪕'];
@@ -44,12 +43,7 @@
let _ppBootstrapped = false; let _ppBootstrapped = false;
let _ppNotified = {}; // badges chimed this session (slam still pending) let _ppNotified = {}; // badges chimed this session (slam still pending)
let _ppGigProposal = null; // the booking poster's proposal, while open let _ppGigProposal = null; // the booking poster's proposal, while open
let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, tuning_pref, idx} mid-set let _ppGigRun = null; // {songs, venue_id, genre, genre_key, instrument, idx} mid-set
let _ppGigTuningPref = 'any'; // loaded from localStorage in boot()
let _ppGigTuningNames = []; // cached distinct tuning names for the specific picker
let _ppGigTuningHold = null; // holdAutoplay release fn — non-null = interstitial active
let _ppGigLastTuning = null; // tuning_name of the current gig song (for change detection)
let _ppBookGen = 0; // generation counter — stale responses are discarded
function $(id) { return document.getElementById(id); } function $(id) { return document.getElementById(id); }
@@ -780,7 +774,6 @@
function closeBook() { function closeBook() {
_ppBook = null; _ppBook = null;
_ppGigProposal = null; // a dismissed poster is a dismissed booking _ppGigProposal = null; // a dismissed poster is a dismissed booking
++_ppBookGen; // invalidate any in-flight bookGig request
const overlay = $('pp-overlay'); const overlay = $('pp-overlay');
if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; } if (overlay) { overlay.classList.add('hidden'); overlay.innerHTML = ''; }
if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' && if (_ppReturnFocus && typeof _ppReturnFocus.focus === 'function' &&
@@ -1092,25 +1085,13 @@
function gigPosterHTML(prop) { function gigPosterHTML(prop) {
const bill = prop.songs.map((s, i) => const bill = prop.songs.map((s, i) =>
`<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}${s.tuning_name ? ` <small class="pp-poster-tuning-chip">${esc(s.tuning_name)}</small>` : ''}</div>`).join(''); `<div class="pp-poster-line"><span>${i + 1}.</span> ${esc(s.title)}${s.artist ? ` <em>${esc(s.artist)}</em>` : ''}</div>`).join('');
const PREF_LABELS = { any: 'Any', standard: 'Standard', drop: 'Drop', specific: 'Specific…' };
const isSpecific = _ppGigTuningPref.startsWith('specific:');
const curPill = isSpecific ? 'specific' : _ppGigTuningPref;
const pills = Object.keys(PREF_LABELS).map((p) =>
`<button data-pp-tuning="${esc(p)}" class="career-btn career-btn-ghost pp-tuning-pill${curPill === p ? ' pp-tuning-pill-on' : ''}">${PREF_LABELS[p]}</button>`
).join('');
const selVal = isSpecific ? _ppGigTuningPref.slice('specific:'.length) : '';
const selOpts = _ppGigTuningNames.length
? `<option value="">Choose tuning…</option>${_ppGigTuningNames.map((n) => `<option value="${esc(n)}"${n === selVal ? ' selected' : ''}>${esc(n)}</option>`).join('')}`
: `<option value="">Loading…</option>`;
const selHidden = isSpecific ? '' : ' hidden';
return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster"> return `<div class="pp-book-wrap" data-pp-close-bg="1" role="dialog" aria-modal="true" aria-label="Gig poster">
<div class="pp-poster"> <div class="pp-poster">
<div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div> <div class="pp-poster-venue">${esc(prop.venue_name || 'The stage')}</div>
<div class="pp-poster-presents">presents</div> <div class="pp-poster-presents">presents</div>
<div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div> <div class="pp-poster-title">${esc(prop.genre.toUpperCase())} NIGHT</div>
<div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div> <div class="pp-poster-inst">${esc(ppLabel(prop.instrument))} · tonight</div>
<div class="pp-tuning-row">${pills}<select data-pp-tuning-select class="pp-tuning-select${selHidden}">${selOpts}</select></div>
<div class="pp-poster-bill">${bill}</div> <div class="pp-poster-bill">${bill}</div>
<div class="pp-poster-actions"> <div class="pp-poster-actions">
<button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button> <button class="career-btn career-btn-primary" data-pp-gig-play="1">Play the gig</button>
@@ -1129,38 +1110,15 @@
const p = (((_pp.instruments || {})[inst] || {}).passports || []) const p = (((_pp.instruments || {})[inst] || {}).passports || [])
.find((x) => x.genre_key === gkey); .find((x) => x.genre_key === gkey);
if (!p) return; if (!p) return;
const gen = ++_ppBookGen; // F1: capture generation before await — stale responses discarded
try { try {
const res = await fetch(`${API}/gigs/propose`, { const res = await fetch(`${API}/gigs/propose`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument: inst, genre: p.genre, tuning_pref: _ppGigTuningPref }), body: JSON.stringify({ instrument: inst, genre: p.genre }),
}); });
if (gen !== _ppBookGen) return; // stale response — a newer request supersedes this one if (!res.ok) return;
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (gen !== _ppBookGen) return; // stale — superseded while awaiting error json()
if (res.status === 404) {
// No-match 404: revert tuning pref to 'any', re-render poster to match
_ppGigTuningPref = 'any';
lsSet(PP_TUNING_PREF_KEY, 'any');
if (_ppGigProposal) {
const overlay = $('pp-overlay');
if (overlay) overlay.innerHTML = gigPosterHTML(_ppGigProposal);
}
}
// All errors: notify (404 has a tuning-specific message; others are generic)
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
const msg = res.status === 404
? (err && err.detail) || 'No songs match that tuning filter.'
: 'Could not book gig — please try again.';
try { window.fbNotify.show({ title: 'Gig booking', message: msg, icon: '🎸' }); } catch (_) { /* */ }
}
return;
}
_ppGigProposal = await res.json(); _ppGigProposal = await res.json();
} catch (_) { return; } } catch (_) { return; }
if (gen !== _ppBookGen) return; // stale — superseded while awaiting json()
const overlay = $('pp-overlay'); const overlay = $('pp-overlay');
if (!overlay) return; if (!overlay) return;
_ppBook = null; // the poster replaces the book in the overlay _ppBook = null; // the poster replaces the book in the overlay
@@ -1169,26 +1127,6 @@
sfx('page'); sfx('page');
} }
async function _openSpecificTuningPicker() {
const overlay = $('pp-overlay');
if (!overlay || !_ppGigProposal) return;
if (_ppGigTuningNames.length) {
const sel = overlay.querySelector('[data-pp-tuning-select]');
if (sel) sel.classList.remove('hidden');
return;
}
try {
const res = await fetch(`${API}/gigs/tunings?genre=${encodeURIComponent(_ppGigProposal.genre)}`);
if (!res.ok) return;
const data = await res.json();
_ppGigTuningNames = Array.isArray(data.tunings) ? data.tunings : [];
} catch (_) { return; }
// Re-render with populated options
overlay.innerHTML = gigPosterHTML(_ppGigProposal);
const sel = overlay.querySelector('[data-pp-tuning-select]');
if (sel) sel.classList.remove('hidden');
}
// Unpack the whole set before the first note. // Unpack the whole set before the first note.
// //
// A feedpak is a zip, and the first play of one pays for its extraction. In // A feedpak is a zip, and the first play of one pays for its extraction. In
@@ -1275,11 +1213,9 @@
genre: prop.genre, genre: prop.genre,
genre_key: prop.genre_key, genre_key: prop.genre_key,
instrument: prop.instrument, instrument: prop.instrument,
tuning_pref: prop.tuning_pref || 'any',
idx: 0, idx: 0,
restore, restore,
}; };
_ppGigLastTuning = null; // reset for fresh interstitial tracking
closeBook(); closeBook();
_ppGigProposal = null; _ppGigProposal = null;
// RAW filenames: the queue itself encodes for playSong — pre-encoding // RAW filenames: the queue itself encodes for playSong — pre-encoding
@@ -1315,14 +1251,8 @@
document.body.appendChild(strip); document.body.appendChild(strip);
} }
const run = _ppGigRun; const run = _ppGigRun;
if (_ppGigTuningHold) { const next = run.songs[run.idx + 1];
const song = run.songs[run.idx]; strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
const tuning = (song && song.tuning_name) ? esc(song.tuning_name) : 'check tuning';
strip.innerHTML = `<b class="pp-gig-tune-label">Tune to: ${tuning}</b><button data-pp-gig-start-song="1" class="career-btn career-btn-primary pp-gig-start-btn">Start song</button>`;
} else {
const next = run.songs[run.idx + 1];
strip.innerHTML = `<b>GIG</b> · ${esc(run.genre)} at ${esc(_venueName(run.venue_id))} · set ${Math.min(run.idx + 1, run.songs.length)}/${run.songs.length}${next ? ` — next: <em>${esc(next.title)}</em>` : ' — closer!'}`;
}
} }
function removeGigStrip() { function removeGigStrip() {
@@ -1334,8 +1264,6 @@
// No fail state: an abandoned set logs nothing and says nothing. // No fail state: an abandoned set logs nothing and says nothing.
const run = _ppGigRun; const run = _ppGigRun;
_ppGigRun = null; _ppGigRun = null;
_ppGigLastTuning = null;
if (_ppGigTuningHold) { const h = _ppGigTuningHold; _ppGigTuningHold = null; h(); }
removeGigStrip(); removeGigStrip();
restoreGigStage(run); restoreGigStage(run);
} }
@@ -1475,32 +1403,6 @@
// Queue lifecycle: advance the strip per song; complete or abandon. // Queue lifecycle: advance the strip per song; complete or abandon.
function onGigSongLoading() { function onGigSongLoading() {
if (!_ppGigRun) return; if (!_ppGigRun) return;
const run = _ppGigRun;
const song = run.songs[run.idx];
const tuningName = (song && song.tuning_name) || '';
const pref = run.tuning_pref || 'any';
// Interstitial: pause before first song (or when tuning changes) for all
// non-specific prefs, so the player has time to retune. "specific" is
// excluded because every song already matches one fixed tuning.
const needsInterstitial = !pref.startsWith('specific:') && (
run.idx === 0 || tuningName !== _ppGigLastTuning
);
_ppGigLastTuning = tuningName;
if (needsInterstitial) {
const fb = window.feedBack;
const holdFn = fb && typeof fb.holdAutoplay === 'function' ? fb.holdAutoplay : null;
_ppGigTuningHold = holdFn ? holdFn() : null;
if (_ppGigTuningHold) {
// Cancel the fail-open backstop — we manage the dismiss ourselves
// (user clicks "Start song"). A song navigation clears the hold anyway.
_ppGigTuningHold.settle();
}
if (_ppGigTuningHold && window.tuner && typeof window.tuner.enable === 'function') {
window.tuner.enable({ auto: true }).catch(() => {});
}
} else {
_ppGigTuningHold = null;
}
renderGigStrip(); renderGigStrip();
} }
@@ -1580,28 +1482,6 @@
closeBook(); closeBook();
return; return;
} }
// Tuning pref pill on the gig poster
const tuningPill = e.target.closest('[data-pp-tuning]');
if (tuningPill) {
const pref = tuningPill.dataset.ppTuning;
if (pref === 'specific') {
_openSpecificTuningPicker();
} else {
_ppGigTuningPref = pref;
lsSet(PP_TUNING_PREF_KEY, pref);
_ppGigTuningNames = []; // reset specific cache on pref change
if (_ppGigProposal) bookGig(_ppGigProposal.genre_key);
}
return;
}
// "Start song" interstitial button (mid-gig tuning pause)
if (e.target.closest('[data-pp-gig-start-song]') && _ppGigTuningHold) {
const release = _ppGigTuningHold;
_ppGigTuningHold = null;
renderGigStrip();
release();
return;
}
const gigBtn = e.target.closest('[data-pp-gig]'); const gigBtn = e.target.closest('[data-pp-gig]');
if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; } if (gigBtn) { bookGig(gigBtn.dataset.ppGig); return; }
if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; } if (e.target.closest('[data-pp-gig-play]')) { startGig(e.target.closest('[data-pp-gig-play]')); return; }
@@ -1659,23 +1539,11 @@
} }
function boot() { function boot() {
// Restore persisted tuning preference
_ppGigTuningPref = lsGet(PP_TUNING_PREF_KEY) || 'any';
const screen = document.getElementById('plugin-career'); const screen = document.getElementById('plugin-career');
if (screen) { if (screen) {
screen.addEventListener('click', onClick); screen.addEventListener('click', onClick);
screen.addEventListener('pointermove', onTiltMove); screen.addEventListener('pointermove', onTiltMove);
screen.addEventListener('pointerleave', onTiltLeave); screen.addEventListener('pointerleave', onTiltLeave);
// Specific-tuning select change: rebook with the chosen tuning
screen.addEventListener('change', (e) => {
const sel = e.target.closest('[data-pp-tuning-select]');
if (!sel || !_ppGigProposal) return;
const val = sel.value;
if (!val) return;
_ppGigTuningPref = 'specific:' + val;
lsSet(PP_TUNING_PREF_KEY, _ppGigTuningPref);
bookGig(_ppGigProposal.genre_key);
});
} }
const sm = window.feedBack; const sm = window.feedBack;
if (sm && typeof sm.on === 'function') { if (sm && typeof sm.on === 'function') {
@@ -1708,20 +1576,10 @@
window.__careerPassportTest = { window.__careerPassportTest = {
ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen, ppKey, ppJitter, ppLabel, detectNewBadges, seenBadges, markBadgeSeen,
fmtHours, ppFillFraction, careerTotals, closestAskHTML, fmtHours, ppFillFraction, careerTotals, closestAskHTML,
onGigSongEnded, onGigSongStop, onGigSongLoading, onGigSongEnded, onGigSongStop,
setGigRun(r) { _ppGigRun = r; }, setGigRun(r) { _ppGigRun = r; },
getGigRun() { return _ppGigRun; }, getGigRun() { return _ppGigRun; },
setView(v) { _pp = v; }, setView(v) { _pp = v; },
getTuningHold() { return _ppGigTuningHold; },
setTuningHold(h) { _ppGigTuningHold = h; },
getTuningPref() { return _ppGigTuningPref; },
setTuningPref(p) { _ppGigTuningPref = p; },
getLastTuning() { return _ppGigLastTuning; },
setLastTuning(t) { _ppGigLastTuning = t; },
getBookGen() { return _ppBookGen; },
setBookGen(g) { _ppBookGen = g; },
getProposal() { return _ppGigProposal; },
bookGig, closeBook,
}; };
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
+10 -18
View File
@@ -1,20 +1,12 @@
{ {
"id": "highway_3d", "id": "highway_3d",
"name": "3D Highway", "name": "3D Highway",
"version": "3.54.0", "version": "3.34.1",
"type": "visualization", "type": "visualization",
"scriptType": "module", "bundled": true,
"bundled": true, "script": "screen.js",
"script": "screen.js", "styles": "assets/plugin.css",
"styles": "assets/plugin.css", "settings": { "html": "settings.html", "category": "graphics", "server_files": ["plugin_uploads/highway_3d/current.mp4", "plugin_uploads/highway_3d/current.webm"] },
"settings": { "routes": "routes.py",
"html": "settings.html", "tour": "tour.json"
"category": "graphics",
"server_files": [
"plugin_uploads/highway_3d/current.mp4",
"plugin_uploads/highway_3d/current.webm"
]
},
"routes": "routes.py",
"tour": "tour.json"
} }
+11679 -907
View File
File diff suppressed because it is too large Load Diff
-850
View File
@@ -1,850 +0,0 @@
// h3d-carve-12: T-section (arpeggio inference) extracted from screen.js.
// VERBATIM-MOVE: all function bodies are byte-for-byte identical to their
// screen.js originals except for the single DI rewire noted below.
// No logic changes, no new guards, no structural additions.
//
// Beyond-subst changes:
// 1. arpeggioLaneDividerXYScaleMatchFrameRim: `nStr` → `getNStr()`
// (the explicit argument `sY(nStr - 1)` — sY itself is a plain shorthand
// that already captures live state internally, but the argument needs getNStr())
// 2. _resetStringDependentCaches: STAYS in screen.js; this module exports
// `resetChordShapeCache()` instead, which screen.js calls to reset
// _chordShapeCache (the only cache that moved here).
//
// lowerBoundT imported directly from ./geometry.js — not in DI surface.
// Total DI params: 19 (18 plain const shorthand + 1 live getter).
// Missed in contract survey (corrected before GO): NEXT_ON_STRING_T_EPS (line 248).
import { lowerBoundT } from './geometry.js';
export function createArp({
// ── plain const shorthand (fn refs or number consts, never reassigned) ──
validString, // IIFE fn decl ~line 3736
filterValidNotes, // IIFE fn decl ~line 3767 — used by arpeggioLaneDividerFrameAccentMul
sY, // factory-scope fn: s => S_BASE + (...) * S_GAP (captures live vars)
K, // module-level const line 124
S_GAP, // module-level const line 203
BEHIND, // module-level const line 206
CHORD_FRAME_RIM_MIN, // line 610
CHORD_FRAME_RIM_FRAC_H, // line 611
ARP_FRAME_ONSET_PAD_S, // line 620
ARP_FRAME_ONSET_CLUSTER_S, // line 621
ARP_INFER_MIN_HAND_SHAPE_SPAN_S, // line 628
ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S, // line 634
ARP_INFER_MULTI_STRUM_HIT_SLACK, // line 640
ARP_INFER_MULTI_STRUM_WIN_MIN_S, // line 642
ARP_INFER_MIN_HITS_VS_SHAPE_CAP, // line 653
ARP_HWY_RAIL_END_TAIL_S, // line 263
ARP_HWY_RAIL_START_LEAD_S, // line 265
NEXT_ON_STRING_T_EPS, // line 248 — used in chordShapeCoveredByStandaloneNotes
// ── live getter (let var, reassigned per arrangement/frame) ──
getNStr, // () => nStr — used in arpeggioLaneDividerXYScaleMatchFrameRim
}) {
// ── Pre-arp utilities ─────────────────────────────────────────────
function truthyChartFlag(v) {
if (v === true || v === 1) return true;
if (v === '1') return true;
return typeof v === 'string' && v.toLowerCase() === 'true';
}
/** RS / sloppak `hd` (highDensity); tolerate occasional string forms. */
function chordWireHighDensity(ch) {
return truthyChartFlag(ch && ch.hd);
}
/**
* Per spec, `displayName` is the UI label for a chord template
* (defaulting to `name` when the chart didn't set it). Always go
* through this helper so name vs. displayName drift can't surface
* the wrong label or break displayName-based dedupe heuristics.
*/
function chordTemplateLabel(tmpl) {
if (!tmpl) return '';
const d = tmpl.displayName;
if (typeof d === 'string' && d.length > 0) return d;
const n = tmpl.name;
return typeof n === 'string' ? n : '';
}
/**
* Arpeggio styling is driven by authored metadata, not by post-hoc
* note-stream inference. Prefer explicit hand-shape flags and fall back
* to template markers when present.
*/
function chordTemplateMarkedArpeggio(cid, chordTemplates) {
if (cid == null || !chordTemplates) return false;
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
if (!tmpl) return false;
if (truthyChartFlag(tmpl.arp) || truthyChartFlag(tmpl.arpeggio)) return true;
const displayName = typeof tmpl.displayName === 'string' ? tmpl.displayName.toLowerCase() : '';
if (displayName.includes('-arp')) return true;
const name = typeof tmpl.name === 'string' ? tmpl.name.toLowerCase() : '';
return name.endsWith('(arp)') || name.includes(' arpeggio');
}
function handShapeMarkedArpeggio(hs, chordTemplates) {
if (!hs) return false;
if (truthyChartFlag(hs.arp) || truthyChartFlag(hs.arpeggio)) return true;
return chordTemplateMarkedArpeggio(hsChordIdNorm(hs), chordTemplates);
}
// ── Hint cache ───────────────────────────────────────────────────
/**
* Matching hand-shape metadata for a chord onset. ``explicit`` follows
* authored arpeggio markers only; note inference is handled separately
* by the callers that still need it for non-visual behavior.
*
* Cached per chord: result depends only on (ch, hss, chordTemplates),
* all chart-static for the lifetime of an arrangement. The cache is
* swapped on (hss, templates) ref change so an arrangement switch
* cannot resurrect stale entries. Empty-input case bypasses the cache
* it returns a fresh sentinel anyway and isn't hot enough to share.
*/
const _HINT_NONE = Object.freeze({ explicit: false, covered: false, hs: null });
let _hintCache = new WeakMap();
let _hintCacheHsRef = null;
let _hintCacheTplRef = null;
function chordHandShapeArpeggioHint(ch, hss, chordTemplates) {
if (!hss || hss.length === 0) return _HINT_NONE;
if (_hintCacheHsRef !== hss || _hintCacheTplRef !== chordTemplates) {
_hintCache = new WeakMap();
_hintCacheHsRef = hss;
_hintCacheTplRef = chordTemplates;
}
const cached = _hintCache.get(ch);
if (cached !== undefined) return cached;
const t = ch.t;
const cid = ch.id;
let result = _HINT_NONE;
for (let i = 0; i < hss.length; i++) {
const hs = hss[i];
const tLo = hsStart(hs);
const tHi = hsEnd(hs);
if (Number.isNaN(tLo) || Number.isNaN(tHi)) continue;
if (t + 1e-4 < tLo || t > tHi + 1e-4) continue;
const hsCid = hsChordIdNorm(hs);
if (hsCid !== cid && Number(hsCid) !== Number(cid)) continue;
const explicit = handShapeMarkedArpeggio(hs, chordTemplates);
result = { explicit, covered: true, hs };
break;
}
_hintCache.set(ch, result);
return result;
}
/** Build ``ch.notes`` from ``chordTemplates[cid].frets`` (-1 omitted). */
function chordNotesFromTemplate(cid, templates) {
if (templates == null || cid == null) return [];
const tmpl = templates[cid] ?? templates[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) return [];
const out = [];
for (let si = 0; si < tmpl.frets.length; si++) {
const f = tmpl.frets[si];
if (f >= 0 && validString(si)) out.push({ s: si, f, sus: 0 });
}
return out;
}
/**
* Chart-format fingerpicking passages often have ``<handShape>`` + per-string
* ``<note>`` rows but **no** ``<chord>`` events. The 3D chord frame / arp
* styling only runs over ``bundle.chords``, so synthesize minimal chord
* rows at each hand-shape onset when the chart omits them.
*/
function mergeHandShapeSynthChords(realChords, handShapes, chordTemplates) {
if (!handShapes || handShapes.length === 0) return realChords;
const reals = realChords && realChords.length ? realChords : [];
const synth = [];
const seenSynth = new Set();
const tol = 0.028;
/**
* Suppress a synth chord box when a real chord with the **same trimmed
* display name** played within this window Custom songs commonly authors
* several ``<chordTemplate>`` rows that share a display name (with
* trailing-whitespace IDs) for fingering variants. The follow-up
* hand-shape with no chord row is a fingering hint, not a new strum
* (e.g. Jackson 5 "I Want You Back" ~0:27 Fm7 cid=18 strum followed
* by Fm7 cid=19 hand-shape, which earlier produced a stacked second
* "Fm7" label and an extra chord frame).
*/
const SAME_NAME_RUN_S = 0.5;
const trimmedTemplateName = (cid) => {
if (cid == null || !chordTemplates) return '';
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
// custom songs commonly authors several <chordTemplate> rows that share
// a displayName for fingering variants; the suppression
// heuristic in the surrounding code dedupes on the *label*,
// not the underlying name, so go through chordTemplateLabel.
return chordTemplateLabel(tmpl).trim();
};
outer: for (let i = 0; i < handShapes.length; i++) {
const hs = handShapes[i];
const cid = hs.chord_id != null ? hs.chord_id : hs.chordId;
const st = hs.start_time != null ? hs.start_time : hs.startTime;
if (cid == null || st == null || Number.isNaN(Number(st))) continue;
const key = `${cid}|${Number(st).toFixed(3)}`;
if (seenSynth.has(key)) continue;
seenSynth.add(key);
const myName = trimmedTemplateName(cid);
for (let j = 0; j < reals.length; j++) {
const ch = reals[j];
const rid = ch.id;
const sameId = rid === cid || Number(rid) === Number(cid);
if (sameId && Math.abs(ch.t - st) <= tol) continue outer;
// A real strum at the same onset already represents this
// chord — never synthesize a phantom on top of it. The
// id/name checks alone miss hand-shapes whose template
// differs from (or shares no name with) the coincident real
// chord — e.g. an edited chart that left a stale hand-shape
// template pointing at the pre-edit shape, which then drew a
// spurious second power chord beside the real one.
if (Math.abs(ch.t - st) <= tol) continue outer;
if (!sameId && myName !== '') {
const otherName = trimmedTemplateName(rid);
if (otherName === myName
&& st > ch.t
&& st - ch.t <= SAME_NAME_RUN_S) {
continue outer;
}
}
}
const notes = chordNotesFromTemplate(cid, chordTemplates);
if (notes.length === 0) continue;
const et = hs.end_time != null ? hs.end_time : hs.endTime;
synth.push({
t: st,
id: cid,
// `hd` is the chart-format `highDensity` wire field (gallops /
// repeated strums), not an arpeggio carrier — arpeggio
// intent is read directly from the hand-shape via
// chordHandShapeArpeggioHint() downstream. Keep `hd` false
// so chordWireHighDensity() / label-suppression behave the
// same as for any other non-gallop chord row.
hd: false,
notes,
/** Hand-shape fill-in (no authored chord row) — skip note-stream arp frame. */
h3dSynth: true,
/** Hand-shape end time — used to draw the shape-sustain border for non-arp cases. */
h3dSynthEnd: et != null ? Number(et) : null,
});
}
if (synth.length === 0) return reals;
const merged = reals.concat(synth);
merged.sort((a, b) => {
const dt = a.t - b.t;
if (Math.abs(dt) > 1e-6) return dt;
const ia = Number(a.id);
const ib = Number(b.id);
return (ia - ib) || 0;
});
return merged;
}
// ── Chord-shape cache ─────────────────────────────────────────────
/**
* Merge chart-format ``chordTemplates[id].frets`` with live ``chordNote`` rows.
* Cached via WeakMap on the chord object chord data never changes after
* chart load, so the Map is computed once and reused every frame.
* The init-time callers (fillArpeggioGhostInferFlags) pass ephemeral `fakeCh`
* objects that are never seen again, so they bypass the cache naturally.
*/
let _chordShapeCache = new WeakMap();
function mergeChordShape(ch, chordNotes, templates) {
if (_chordShapeCache.has(ch)) return _chordShapeCache.get(ch);
const shape = new Map();
const tid = ch && ch.id != null ? ch.id : null;
const tmpl = (tid != null && templates)
? (templates[tid] ?? templates[Number(tid)])
: null;
if (tmpl && Array.isArray(tmpl.frets)) {
for (let si = 0; si < tmpl.frets.length; si++) {
if (!validString(si)) continue;
const f = tmpl.frets[si];
if (f >= 0) shape.set(si, f);
}
}
for (let i = 0; i < chordNotes.length; i++) {
const cn = chordNotes[i];
if (!validString(cn.s)) continue;
if (cn.f < 0) shape.delete(cn.s);
else shape.set(cn.s, cn.f);
}
_chordShapeCache.set(ch, shape);
return shape;
}
// h3d-carve-12: screen.js's _resetStringDependentCaches() calls this
// instead of directly assigning `_chordShapeCache = new WeakMap()`.
// Reset the validString()/nStr-dependent chord caches. Called when nStr
// changes so a string count discovered after the first frame (e.g. a
// 7-string chart whose stringCount arrives in song_info) doesn't leave
// string-6+ notes filtered out of cached chord shapes/signatures.
function resetChordShapeCache() {
_chordShapeCache = new WeakMap();
}
function hitTimesQualifyArpeggioSpread(hitTimes) {
if (hitTimes.length < 2) return false;
hitTimes.sort((a, b) => a - b);
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
if (spread >= 0.03) return true;
return hitTimes.length >= 4 && spread >= 0.016;
}
/** RS XML / IPC payloads use snake_case or camelCase field names. */
function hsStart(hs) {
if (!hs) return NaN;
const v = hs.start_time != null ? hs.start_time : hs.startTime;
if (v == null) return NaN;
const n = Number(v);
return Number.isNaN(n) ? NaN : n;
}
function hsEnd(hs) {
if (!hs) return NaN;
const v = hs.end_time != null ? hs.end_time : hs.endTime;
if (v == null) return NaN;
const n = Number(v);
return Number.isNaN(n) ? NaN : n;
}
function hsChordIdNorm(hs) {
if (!hs) return null;
const v = hs.chord_id != null ? hs.chord_id : hs.chordId;
return v == null ? null : v;
}
/** ``<handShape>`` chart duration in seconds (snake_case or camelCase XML). */
function handShapeChartSpanSec(hs) {
const a = hsStart(hs), b = hsEnd(hs);
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
return Math.max(0, b - a);
}
// ── Infer-pattern cache ───────────────────────────────────────────
/**
* When ``hd`` is missing/false, detect arpeggio from the **note** stream
* using the **full voicing** (template chord notes). RS often stores the
* plucks only in ``notes[]``, not as duplicate chord rows.
*
* @param {{ tLo: number, tHi: number } | null} [timeWin]
* When set (e.g. from ``<handShape>`` span), scan staggered picks
* across the whole held-shape window RS often omits ``arp`` and ``hd``.
*/
// Cached per chord: result depends on (ch, shape, notesArr) and an
// optional timeWin which itself is a function of the chord's matching
// <handShape>. Both inputs are chart-static, so the cache invalidates
// on (notesArr, hss) ref change — `hss` is threaded in purely as the
// invalidation key for the chord-loop caller, which passes a stable
// `ch` (reused across frames) and a timeWin that is null until
// bundle.handShapes arrives over the WS; without the hss check the
// null-timeWin result would stick once handShapes loaded late. shape
// comes from mergeChordShape(ch) which is also chart-static, so it
// doesn't enter the invalidation key directly. The cache deliberately
// stores boolean results; a sentinel distinguishes "not computed"
// from "false".
let _arpInferCache = new WeakMap();
let _arpInferCacheNotesRef = null;
let _arpInferCacheHssRef = null;
function inferArpeggioFromNotePattern(ch, shape, notesArr, timeWin, hss = null) {
if (!notesArr || notesArr.length === 0 || shape.size < 2) return false;
if (_arpInferCacheNotesRef !== notesArr || _arpInferCacheHssRef !== hss) {
_arpInferCache = new WeakMap();
_arpInferCacheNotesRef = notesArr;
_arpInferCacheHssRef = hss;
}
const cached = _arpInferCache.get(ch);
if (cached !== undefined) return cached;
const result = _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin);
_arpInferCache.set(ch, result);
return result;
}
function _inferArpeggioFromNotePatternUncached(ch, shape, notesArr, timeWin) {
const tHi = timeWin ? timeWin.tHi : ch.t + 2.35;
const tLo = timeWin ? timeWin.tLo : ch.t - 0.28;
let i2 = lowerBoundT(notesArr, tLo - 0.02);
const hitTimes = [];
const hitStrings = new Set();
for (; i2 < notesArr.length; i2++) {
const n = notesArr[i2];
if (n.t > tHi) break;
if (n.t < tLo) continue;
if (!validString(n.s)) continue;
const ef = shape.get(n.s);
if (ef === undefined || ef !== n.f) continue;
hitTimes.push(n.t);
hitStrings.add(n.s);
}
if (!hitTimesQualifyArpeggioSpread(hitTimes)) return false;
// A genuine arpeggio SWEEPS across the held shape, so its standalone
// notes land on MULTIPLE strings of the shape. When every matching
// hit is on a single string, this is a repeated single-string run
// (e.g. a palm-muted gallop hammering the chord's root) that happens
// to share one string/fret with the chord — NOT an arpeggio. Inferring
// one here deferred the chord's gems and made the power chord render as
// just that one repeated note (bar 25 of starlight). Require ≥2 strings.
if (hitStrings.size < 2) return false;
// Strumming/gallop rejection — far more hits than the shape has
// strings means the chord's notes are being re-struck repeatedly
// (a riff/gallop reusing both power-chord notes), not swept once as
// an arpeggio. This guard used to live inside `if (timeWin)`, so it
// was skipped for charts with no hand-shapes (timeWin null) — which
// let dense two-string gallops over a power chord infer a bogus
// arpeggio and defer the chord's gems (bar 88 of starlight: a
// (s5:4,s6:2) chord whose root+fifth recur ~16x over 2 s). Apply it
// with the actual window span whether or not a hand-shape is present.
const winSpan = timeWin ? (timeWin.tHi - timeWin.tLo) : (tHi - tLo);
if (winSpan > ARP_INFER_MULTI_STRUM_WIN_MIN_S
&& hitTimes.length > shape.size + ARP_INFER_MULTI_STRUM_HIT_SLACK) {
return false;
}
if (timeWin) {
if (winSpan < 0.70 && hitTimes.length < 4) {
const spread = hitTimes[hitTimes.length - 1] - hitTimes[0];
if (spread < ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S) return false;
}
// Reject when too few staggered hits for a genuine sweep across
// the held shape — see ARP_INFER_MIN_HITS_VS_SHAPE_CAP.
const minHits = Math.min(shape.size, ARP_INFER_MIN_HITS_VS_SHAPE_CAP);
if (hitTimes.length < minHits) return false;
}
return true;
}
/**
* True when standalone note rows already cover every string/fret in the
* arpeggio shape, so drawing the chord gems too would duplicate the same
* authored passage.
*/
// Cached per chord: result depends on (ch, shape, notesArr) — chart-
// static; the cache invalidates on notesArr ref change. The same
// ``ch`` may be queried multiple times per frame from the chord
// render loop (deferChordGems / _deferFallback / suppressSynthChord),
// so survival across frames is also useful.
let _arpCoverCache = new WeakMap();
let _arpCoverCacheNotesRef = null;
function chordShapeCoveredByStandaloneNotes(ch, shape, notesArr, timeWin) {
if (!notesArr || notesArr.length === 0 || !shape || shape.size === 0) return false;
if (_arpCoverCacheNotesRef !== notesArr) {
_arpCoverCache = new WeakMap();
_arpCoverCacheNotesRef = notesArr;
}
const cached = _arpCoverCache.get(ch);
if (cached !== undefined) return cached;
const tLo = (timeWin ? timeWin.tLo : ch.t - ARP_FRAME_ONSET_PAD_S) - NEXT_ON_STRING_T_EPS;
const tHi = (timeWin ? timeWin.tHi : ch.t + ARP_FRAME_ONSET_CLUSTER_S) + NEXT_ON_STRING_T_EPS;
let i2 = lowerBoundT(notesArr, tLo);
const matchedStrings = new Set();
let result = false;
for (; i2 < notesArr.length; i2++) {
const n = notesArr[i2];
if (n.t > tHi) break;
if (!validString(n.s) || matchedStrings.has(n.s)) continue;
const ef = shape.get(n.s);
if (ef === undefined || ef !== n.f) continue;
matchedStrings.add(n.s);
if (matchedStrings.size >= shape.size) { result = true; break; }
}
_arpCoverCache.set(ch, result);
return result;
}
/**
* Notes in an inferred arpeggio passage are charted in ``notes[]`` with
* staggered times; treat them like chord-cluster notes for chart-format-style
* board-ghost fret digits (``fromChord`` + template column).
*/
function arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr) {
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0) return null;
if (!validString(n.s)) return null;
for (let i = 0; i < handShapes.length; i++) {
const hs = handShapes[i];
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
const cid = hsChordIdNorm(hs);
if (cid == null) continue;
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
if (synthNotes.length === 0) continue;
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) continue;
if (inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes)) return cid;
}
return null;
}
/**
* Per-frame warmup: ``inferArpeggioFromNotePattern`` depends only on
* ``handShape × chart``, not on the candidate note the old path
* recomputed it for every visible note (O(notecount × hs × notescan)).
* Fill ``outFlags[i]`` with the boolean once per ``handShapes[i]``.
*/
function fillArpeggioGhostInferFlags(handShapes, chordTemplates, notesArr, outFlags, outSynthOnsetSet = null) {
for (let i = 0; i < handShapes.length; i++) {
let infer = false;
const hs = handShapes[i];
if (handShapeChartSpanSec(hs) < ARP_INFER_MIN_HAND_SHAPE_SPAN_S) {
outFlags[i] = false;
continue;
}
const cid = hsChordIdNorm(hs);
if (cid != null && notesArr.length > 0) {
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (tmpl && Array.isArray(tmpl.frets)) {
const synthNotes = chordNotesFromTemplate(cid, chordTemplates);
if (synthNotes.length > 0) {
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
const fakeCh = { t: hsLo, id: cid, notes: synthNotes };
const shape = mergeChordShape(fakeCh, synthNotes, chordTemplates);
const tw = { tLo: hsLo - 0.06, tHi: hsHi + 0.06 };
infer = inferArpeggioFromNotePattern(fakeCh, shape, notesArr, tw, handShapes);
// Chord-hold gate: inferArpeggioFromNotePattern can fire true
// when open-string notes coincidentally match the template's
// open positions but only a SINGLE fretted (f>0) string is
// actually played at the handshape onset. Treat that as a
// chord hold (not an arpeggio) — clear the arp flag, no
// brackets. The original implementation also intended to
// record a synthetic sustain extending to hsEnd for the
// onset note, but that read-side was never wired up; the
// visual decay-before-handshape-end is benign.
if (infer) {
let _frettedCount = 0;
let _onsetNote = null;
const _fSeen = new Set();
let _ci = lowerBoundT(notesArr, tw.tLo - 0.02);
for (; _ci < notesArr.length; _ci++) {
const _cn = notesArr[_ci];
if (_cn.t > tw.tHi + 0.02) break;
if (_cn.t < tw.tLo) continue;
if (!validString(_cn.s)) continue;
if (shape.get(_cn.s) !== _cn.f) continue;
if (_cn.f > 0 && !_fSeen.has(_cn.s)) {
_frettedCount++;
_fSeen.add(_cn.s);
if (_onsetNote === null) _onsetNote = _cn;
}
}
if (_frettedCount <= 1 && _onsetNote !== null) {
outFlags[i] = false;
continue; // chord hold handled — skip onset-match and outFlags assignment
}
}
// Non-arp template inferred as arpeggio: suppress brackets.
// Only explicit arp-marked templates (arp:true / displayName "-arp")
// should show [ ] / < > bracket markers.
if (infer && outSynthOnsetSet != null
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
outSynthOnsetSet.add(hsLo);
}
// Also treat as arp ghost when the hs generated a suppressed
// synth chord: any standalone note in the onset window matches
// any shape string. Handles patterns where inferArpeggioFromNotePattern
// returns false (e.g. repeated arpeggio across a long hs span
// triggers the multi-strum rejection), but the player still
// needs the "hold this shape" ghost fret numbers on the board.
if (!infer) {
const _oLo = hsLo - ARP_FRAME_ONSET_PAD_S;
const _oHi = hsLo + ARP_FRAME_ONSET_CLUSTER_S;
let _oi = lowerBoundT(notesArr, _oLo - 0.02);
for (; _oi < notesArr.length; _oi++) {
const _on = notesArr[_oi];
if (_on.t > _oHi) break;
if (_on.t < _oLo) continue;
if (shape.get(_on.s) === _on.f) {
infer = true;
// Only suppress brackets when the handshape is NOT an
// explicit arpeggio (arp:true template / displayName "-arp").
// Genuine arp handshapes reached via onset-match still need
// the [ ] bracket markers — only non-arp synth chords are
// "false positives" that should hide the brackets.
if (outSynthOnsetSet != null
&& !handShapeMarkedArpeggio(hs, chordTemplates)) {
outSynthOnsetSet.add(hsLo);
}
break;
}
}
}
}
}
}
outFlags[i] = infer;
}
}
// Chart-static WeakMap cache: note object → chord-id (or null sentinel).
// The result depends only on the note's (t, s, f) and the chart's handShapes
// + chordTemplates, which never change after load. Keyed by note object so
// switching songs/arrangements drops the entries with the old array.
const _ARP_CID_NULL = Object.freeze({});
const _arpCidCache = new WeakMap();
function arpeggioChordIdForNoteWithInferCache(n, handShapes, chordTemplates, notesArr, hsInferFlags) {
const cached = _arpCidCache.get(n);
if (cached !== undefined) return cached === _ARP_CID_NULL ? null : cached;
let result = null;
if (!handShapes || handShapes.length === 0 || !notesArr || notesArr.length === 0 || !hsInferFlags) {
result = arpeggioChordIdForNote(n, handShapes, chordTemplates, notesArr);
} else if (validString(n.s)) {
for (let i = 0; i < handShapes.length; i++) {
if (!hsInferFlags[i]) continue;
const hs = handShapes[i];
const hsLo = hsStart(hs);
const hsHi = hsEnd(hs);
if (Number.isNaN(hsLo) || Number.isNaN(hsHi)) continue;
if (n.t + 1e-4 < hsLo || n.t > hsHi + 1e-4) continue;
const cid = hsChordIdNorm(hs);
if (cid == null) continue;
const tmpl = chordTemplates?.[cid] ?? chordTemplates?.[Number(cid)];
if (!tmpl || !Array.isArray(tmpl.frets)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
result = cid;
break;
}
}
_arpCidCache.set(n, result === null ? _ARP_CID_NULL : result);
return result;
}
/** Returns {start, end} chart-time bounds of the arpeggio handshape that contains
* this note, or null when not found. Uses hsInferFlags to skip ruled-out
* handshapes; falls back to a full scan when hsInferFlags is null. */
// WeakMap cache — arpHsBoundsForNote result is chart-static (note, handShapes,
// and hsInferFlags never change after chart load). Each renderer instance has
// its own WeakMap, so splitscreen panels don't interfere.
// Sentinel: _ARP_BOUNDS_NULL = {} distinguishes "no matching hs" from "uncached".
const _ARP_BOUNDS_NULL = Object.freeze({});
const _arpBoundsCache = new WeakMap();
function arpHsBoundsForNote(n, handShapes, hsInferFlags) {
if (!handShapes || handShapes.length === 0) return null;
const cached = _arpBoundsCache.get(n);
if (cached !== undefined) return cached === _ARP_BOUNDS_NULL ? null : cached;
let result = null;
for (let i = 0; i < handShapes.length; i++) {
if (hsInferFlags && !hsInferFlags[i]) continue;
const hs = handShapes[i];
const lo = hsStart(hs);
const hi = hsEnd(hs);
if (Number.isNaN(lo) || Number.isNaN(hi)) continue;
if (n.t + 1e-4 < lo || n.t > hi + 1e-4) continue;
result = { start: lo, end: hi };
break;
}
_arpBoundsCache.set(n, result === null ? _ARP_BOUNDS_NULL : result);
return result;
}
/** Cache the authored arpeggio marker per hand shape. */
function handShapeIsArpeggioForLaneRail(hs, chordTemplates) {
return handShapeMarkedArpeggio(hs, chordTemplates);
}
/**
* Chart-time window for purple rails: hand-shape span clipped to matching
* ``chords[].t`` and template notes in the passage same times that drive
* the 3D arpeggio frame (``ch.t`` + note stream), avoiding rails that start
* before the box or end before the last arpeggiated note.
*/
function effectiveArpRailChartBoundsForHandShape(hs, chords, chordTemplates, notesArr) {
let shapeLo = hsStart(hs);
const _hsEndOrig = hsEnd(hs);
let shapeHi = _hsEndOrig;
const cid = hsChordIdNorm(hs);
if (Number.isNaN(shapeLo) || Number.isNaN(shapeHi)) {
return { shapeLo: 1e9, shapeHi: -1e9 };
}
if (notesArr && notesArr.length > 0 && chordTemplates && cid != null) {
const tmpl = chordTemplates[cid] ?? chordTemplates[Number(cid)];
if (tmpl && Array.isArray(tmpl.frets)) {
let tFirst = null;
let tLast = null;
for (let i = 0; i < notesArr.length; i++) {
const n = notesArr[i];
if (n.t + 1e-4 < shapeLo - 0.18 || n.t > shapeHi + 0.45) continue;
if (!validString(n.s)) continue;
const tf = tmpl.frets[n.s];
if (typeof tf !== 'number' || tf < 0 || n.f !== tf) continue;
if (tFirst === null || n.t < tFirst) tFirst = n.t;
if (tLast === null || n.t > tLast) tLast = n.t;
}
if (tFirst != null) shapeLo = Math.max(shapeLo, tFirst);
if (tLast != null) shapeHi = Math.max(shapeHi, tLast);
}
}
if (chords && chords.length && cid != null) {
let tMinC = null;
let tMaxC = null;
for (let j = 0; j < chords.length; j++) {
const ch = chords[j];
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
if (ch.t + 1e-4 < shapeLo || ch.t > shapeHi + 0.28) continue;
if (tMinC === null || ch.t < tMinC) tMinC = ch.t;
if (tMaxC === null || ch.t > tMaxC) tMaxC = ch.t;
}
if (tMinC != null) shapeLo = Math.max(shapeLo, tMinC);
if (tMaxC != null) shapeHi = Math.max(shapeHi, tMaxC);
}
shapeLo -= ARP_HWY_RAIL_START_LEAD_S;
// Only extend past the handshape end when notes/chords genuinely reach
// beyond it — otherwise the tail would make the rail visually larger
// than the actual handshape duration (e.g. 0.38 s / 1.3 s ≈ 29% extra).
if (shapeHi > _hsEndOrig) shapeHi += ARP_HWY_RAIL_END_TAIL_S;
return { shapeLo, shapeHi };
}
/** Cache the authored arpeggio marker per hand shape. */
function fillLaneRailHandShapeFlags(handShapes, chordTemplates, outFlags) {
const nHs = handShapes.length;
for (let i = 0; i < nHs; i++) {
outFlags[i] = handShapeIsArpeggioForLaneRail(handShapes[i], chordTemplates);
}
}
function fillArpeggioRailShapeBoundsCaches(
handShapes, chords, chordTemplates, notesArr, laneRailFlags, loOut, hiOut,
) {
const nHs = handShapes.length;
for (let i = 0; i < nHs; i++) {
if (!laneRailFlags[i]) continue;
const b = effectiveArpRailChartBoundsForHandShape(
handShapes[i], chords, chordTemplates, notesArr,
);
loOut[i] = b.shapeLo;
hiOut[i] = b.shapeHi;
}
}
/** ``[tChartLo,tChartHi]`` chart times that a lane slice covers (see module ``BEHIND`` / approach ``dt``). */
function arpeggioLaneOuterRailChartIntervalOverlaps(
tChartLo,
tChartHi,
handShapes,
boundLo,
boundHi,
laneRailFlags,
) {
if (!handShapes || handShapes.length === 0) return false;
if (!laneRailFlags) return false;
if (tChartHi < tChartLo) {
const s = tChartLo;
tChartLo = tChartHi;
tChartHi = s;
}
for (let i = 0; i < handShapes.length; i++) {
if (!laneRailFlags[i]) continue;
const shapeLo = boundLo[i];
const shapeHi = boundHi[i];
if (tChartHi < shapeLo - 1e-4 || tChartLo > shapeHi + 1e-4) continue;
return true;
}
return false;
}
function arpeggioLaneOuterRailLaneSlice(
dt0, dt1, nowClock,
handShapes, boundLo, boundHi, laneRailFlags,
) {
const tLo = nowClock + Math.min(dt0, dt1) - BEHIND;
const tHi = nowClock + Math.max(dt0, dt1) - BEHIND;
return arpeggioLaneOuterRailChartIntervalOverlaps(
tLo, tHi, handShapes, boundLo, boundHi, laneRailFlags,
);
}
/**
* True when **chart time** ``chartT`` falls inside an arpeggio hand-shape.
* Uses a short end tail only no ``CHORD_HWY_LINGER_S`` so purple lane
* rails match visible highway slices and do not leak after shapes end.
*/
function arpeggioLaneOuterRailAtChartTime(
chartT, handShapes, boundLo, boundHi, laneRailFlags,
) {
return arpeggioLaneOuterRailChartIntervalOverlaps(
chartT, chartT, handShapes, boundLo, boundHi, laneRailFlags,
);
}
/**
* Same ``chordAccent ? ft *= 1.22`` as the 3D arpeggio chord rim so lane
* rails match an accented frame when the active hand shape links to a
* chord row that carries ``.ac`` notes.
*/
function arpeggioLaneDividerFrameAccentMul(nowT, handShapes, chords, boundLo, boundHi, laneRailFlags) {
if (!handShapes || handShapes.length === 0 || !chords || chords.length === 0) return 1;
if (!laneRailFlags) return 1;
for (let i = 0; i < handShapes.length; i++) {
if (!laneRailFlags[i]) continue;
const shapeLo = boundLo[i];
const shapeHi = boundHi[i];
if (nowT + 1e-4 < shapeLo || nowT > shapeHi + 1e-4) continue;
const cid = hsChordIdNorm(handShapes[i]);
if (cid == null) return 1;
for (let j = 0; j < chords.length; j++) {
const ch = chords[j];
if (ch.id !== cid && Number(ch.id) !== Number(cid)) continue;
if (Math.abs(ch.t - hsStart(handShapes[i])) > 0.12) continue;
const chordNotes = ch.notes ? filterValidNotes(ch.notes) : [];
if (chordNotes.some(cn => cn.ac)) return 1.22;
return 1;
}
return 1;
}
return 1;
}
/** World-scale XY for purple lane rails = arpeggio ``ftSide`` / ``gLaneDivider`` edge (0.15×K). */
function arpeggioLaneDividerXYScaleMatchFrameRim(accentMul = 1) {
const yA = sY(0), yB = sY(getNStr() - 1); // DI: nStr → getNStr()
const yMinF = Math.min(yA, yB) - S_GAP * 0.8;
const yMaxF = Math.max(yA, yB) + S_GAP * 0.8;
const fullChordBoxH = yMaxF - yMinF;
let ft = Math.max(CHORD_FRAME_RIM_MIN * K, fullChordBoxH * CHORD_FRAME_RIM_FRAC_H);
if (accentMul !== 1 && accentMul > 0) ft *= accentMul;
const ftSide = ft * 1.55;
return ftSide / (0.15 * K);
}
return {
// ── exported (called from outside T-section) ──────────────────────
chordWireHighDensity, // callers: 9155, 9384, 9791, 9815
chordTemplateLabel, // callers: 9790, 10801, 10852
chordTemplateMarkedArpeggio, // callers: 9474, 10045
chordHandShapeArpeggioHint, // caller: 9112
mergeHandShapeSynthChords, // caller: 7965
mergeChordShape, // caller: 8982
resetChordShapeCache, // caller: _resetStringDependentCaches (screen.js)
inferArpeggioFromNotePattern, // caller: 9126
chordShapeCoveredByStandaloneNotes, // caller: 9134
hsStart, // callers: 8008, 9114, 9152, 9239, 9423, 10040
hsEnd, // callers: 8008, 9114, 9240, 9423, 10040
handShapeChartSpanSec, // caller: 9125
fillArpeggioGhostInferFlags, // caller: 7987
arpeggioChordIdForNoteWithInferCache, // caller: 8811
arpHsBoundsForNote, // caller: 8819
fillLaneRailHandShapeFlags, // caller: 8101
fillArpeggioRailShapeBoundsCaches, // caller: 8110
arpeggioLaneOuterRailLaneSlice, // caller: 10267
arpeggioLaneOuterRailAtChartTime, // caller: 10124
arpeggioLaneDividerFrameAccentMul, // callers: 10128, 10366
arpeggioLaneDividerXYScaleMatchFrameRim, // callers: 10133, 10371
// ── private (T-internal only, not in return) ──────────────────────
// truthyChartFlag — only used by T-internal fns
// handShapeMarkedArpeggio — only used by T-internal fns
// chordNotesFromTemplate — only used by T-internal fns
// hitTimesQualifyArpeggioSpread — only called by _inferArpeggioFromNotePatternUncached
// _inferArpeggioFromNotePatternUncached — only called by inferArpeggioFromNotePattern
// hsChordIdNorm — only used by T-internal fns
// arpeggioChordIdForNote — only called by arpeggioChordIdForNoteWithInferCache (line 7378)
// handShapeIsArpeggioForLaneRail — only called by fillLaneRailHandShapeFlags (line 7490)
// effectiveArpRailChartBoundsForHandShape — only called by fillArpeggioRailShapeBoundsCaches (line 7500)
// arpeggioLaneOuterRailChartIntervalOverlaps — only called by Slice/AtChartTime (lines 7540, 7553)
};
}
-684
View File
@@ -1,684 +0,0 @@
/**
* Butterchurn audio-reactive background control panel h3d-carve-4.
*
* Exports _bcIsDesktop() and _bcCreateController().
* window.h3dBcApplySettings is assigned at module scope so it is available
* before the IIFE runs (R5); settings.html guards the call with ?.(). All
* vendor scripts are loaded via DOM <script> injection never ES import (R2).
*/
/* Butterchurn audio-reactive background
* Mounts a Butterchurn (WebGL MilkDrop) canvas BEHIND the transparent
* 3D highway. On desktop it's driven by the guitar/mic input (the song
* audio lives in JUCE, not the webview <audio>); in a browser it taps
* the song <audio> directly.
* */
const BC_VENDOR = '/api/plugins/highway_3d/assets/vendor/';
const BC_FRAME = 1024;
const BC_WORKLET = '/api/plugins/highway_3d/assets/viz-worklet.js';
const _bcMeters = { gtr: 0, song: 0 }; // live levels shown in the panel readout
const BC_BTN = 'background:rgba(255,255,255,.09);color:#cfe3ff;border:1px solid rgba(255,255,255,.16);border-radius:5px;padding:3px 8px;cursor:pointer;font:12px system-ui';
let _bcLoading = null;
function _bcLoadLib() {
if (_bcLoading) return _bcLoading;
_bcLoading = new Promise((resolve, reject) => {
const add = (url, next) => {
const s = document.createElement('script');
s.src = url; s.async = true;
s.onload = next; s.onerror = () => reject(new Error('load ' + url));
document.head.appendChild(s);
};
add(BC_VENDOR + 'butterchurn.min.js', () =>
add(BC_VENDOR + 'butterchurnPresets.min.js', resolve));
});
// Don't cache a rejected promise: a transient load failure (network
// hiccup, blocked request) must not permanently disable the feature for
// the session. Clearing _bcLoading lets the next mount retry the load.
_bcLoading.catch(() => { _bcLoading = null; });
return _bcLoading;
}
function _bcResolve() { let b = window.butterchurn; if (b && b.default) b = b.default; return b; }
function _bcPresets() { let p = window.butterchurnPresets; if (p && p.default) p = p.default; return p; }
export function _bcIsDesktop() {
const d = window.feedBackDesktop || window.slopsmithDesktop;
return !!(d && d.isDesktop && d.audio && typeof d.audio.getRawAudioFrame === 'function');
}
// Fast-forward an index to the first entry after time `ct` (used on seek/loop).
// Position at the first entry whose time is >= ct (strict <), so an event
// landing exactly on the seek/loop target time is still fired by the update
// walkers (which consume `<= ct`) instead of being skipped past here.
export function _bcFfIdx(arr, ct, key) { if (!arr) return 0; let i = 0; while (i < arr.length && (arr[i][key] || 0) < ct) i++; return i; }
// Force-free a canvas's WebGL context so the GPU resources are released
// immediately instead of lingering until GC — repeated Butterchurn
// mount/unmount cycles otherwise pile up live contexts toward the browser cap.
function _bcReleaseCanvasGL(canvas) {
if (!canvas || typeof canvas.getContext !== 'function') return;
let gl = null;
try { gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); } catch (e) { gl = null; }
if (!gl || typeof gl.getExtension !== 'function') return;
try { const lose = gl.getExtension('WEBGL_lose_context'); if (lose) lose.loseContext(); } catch (e) {}
}
// Desktop: bridge GUITAR input PCM + SONG output level into a Web Audio node
// Butterchurn can tap. Guitar gives spectral texture from your playing; the
// song's output meter (getLevels) injects an energy pulse so the visuals also
// react to the backing track (JUCE plays it — there's no song PCM to FFT).
function _bcGuitarFeed(actx, onReady) {
const latest = new Float32Array(BC_FRAME);
let polling = true, songLevel = 0, chartLevel = 0;
let node = null, sp = null, silent = null;
const api = (window.feedBackDesktop || window.slopsmithDesktop).audio;
const gainNow = () => (_bcLoadSettings().guitarGain) || 6;
// Keep the source node processing (silently — JUCE already monitors the
// guitar), and hand it to Butterchurn via the onReady callback.
function attach(srcNode) {
silent = actx.createGain(); silent.gain.value = 0;
srcNode.connect(silent); silent.connect(actx.destination);
try { if (onReady) onReady(srcNode); } catch (e) {}
}
// Fallback for contexts without AudioWorklet support.
function useScriptProcessor() {
let phase = 0, phase2 = 0;
const TWO_PI = Math.PI * 2;
const oscStep = TWO_PI * (90 / actx.sampleRate);
const oscStep2 = TWO_PI * (520 / actx.sampleRate);
sp = actx.createScriptProcessor(BC_FRAME, 1, 1);
sp.onaudioprocess = (e) => {
const out = e.outputBuffer.getChannelData(0);
const n = Math.min(out.length, latest.length);
const lvl = songLevel, clvl = chartLevel, gg = gainNow();
for (let i = 0; i < out.length; i++) {
const g = (i < n ? latest[i] : 0) * gg;
const song = lvl * (0.7 * Math.sin(phase) + 0.3 * (Math.random() * 2 - 1)) * 1.4;
const chart = clvl * (0.5 * Math.sin(phase2) + 0.5 * (Math.random() * 2 - 1)) * 1.5;
phase += oscStep; if (phase > TWO_PI) phase -= TWO_PI;
phase2 += oscStep2; if (phase2 > TWO_PI) phase2 -= TWO_PI;
const v = g + song + chart;
out[i] = v > 1 ? 1 : (v < -1 ? -1 : v);
}
};
attach(sp);
console.log('[viz3d] audio feed: ScriptProcessor (fallback)');
}
// Preferred path: AudioWorklet (runs off the main thread).
if (actx.audioWorklet && typeof actx.audioWorklet.addModule === 'function' && typeof AudioWorkletNode === 'function') {
actx.audioWorklet.addModule(BC_WORKLET).then(() => {
if (!polling || sp) return;
node = new AudioWorkletNode(actx, 'viz-feed', { numberOfInputs: 0, numberOfOutputs: 1, outputChannelCount: [1] });
attach(node);
console.log('[viz3d] audio feed: AudioWorklet');
}).catch((e) => {
console.warn('[viz3d] AudioWorklet unavailable, using ScriptProcessor:', e && e.message);
if (polling && !sp && !node) useScriptProcessor();
});
} else {
useScriptProcessor();
}
// Guitar PCM poll → waveform + level meter (+ pushed to the worklet).
(function pcmLoop() {
if (!polling) return;
Promise.resolve(api.getRawAudioFrame(BC_FRAME)).then((f) => {
if (f && f.length) {
if (f.length >= BC_FRAME) latest.set(f.subarray(0, BC_FRAME));
else { latest.fill(0); latest.set(f); }
let s = 0; for (let i = 0; i < BC_FRAME; i++) s += latest[i] * latest[i];
_bcMeters.gtr = Math.sqrt(s / BC_FRAME) * gainNow();
if (node) node.port.postMessage({ frame: latest.slice(0), song: songLevel, chart: chartLevel, gain: gainNow() });
}
}).catch(() => {}).then(() => { if (polling) setTimeout(pcmLoop, 16); });
})();
// Song output meter poll → music energy pulse.
(function levelLoop() {
if (!polling) return;
Promise.resolve(api.getLevels && api.getLevels()).then((L) => {
if (L && typeof L.outputLevel === 'number') {
songLevel = Math.min(1, L.outputLevel * ((_bcLoadSettings().songGain) || 1.8));
_bcMeters.song = songLevel;
if (node) node.port.postMessage({ song: songLevel, chart: chartLevel, gain: gainNow() });
}
}).catch(() => {}).then(() => { if (polling) setTimeout(levelLoop, 40); });
})();
return {
setChart(v) { chartLevel = v; },
stop() {
polling = false;
try { if (sp) { sp.disconnect(); sp.onaudioprocess = null; } } catch (e) {}
try { if (node) node.disconnect(); } catch (e) {}
try { if (silent) silent.disconnect(); } catch (e) {}
}
};
}
// Browser audio is sourced by REUSING the highway's own shared analyser
// (the same #audio / stems side-chain tap the fog scenery uses), passed in
// as `audioProvider` to _bcCreateController. We deliberately do NOT open a
// second createMediaElementSource on #audio here: it can only be called
// once per element (a second tap throws InvalidStateError and permanently
// disables the other consumer), it would route the song through a fresh,
// possibly-suspended context and mute playback, and it would miss the stems
// side-chain that sloppaks expose at window.feedBack.stems.getAnalyser().
/* ── Controls + readability (localStorage-backed, global config) ───── */
const BC_LS = 'viz3d_settings';
const BC_DEFAULTS = { enabled: true, opacity: 1.0, laneDim: true, laneDimStrength: 0.45, chartAccents: true, colorTint: true, chartStrength: 1.0, tintStrength: 0.65, guitarGain: 6, songGain: 1.8, cyclePool: 'all', hold: false };
let _bcSettings = null;
export function _bcLoadSettings() {
if (_bcSettings) return _bcSettings;
let saved = {};
try { saved = JSON.parse(localStorage.getItem(BC_LS) || '{}'); } catch (e) {}
_bcSettings = Object.assign({}, BC_DEFAULTS, saved);
return _bcSettings;
}
function _bcSaveSettings() { try { localStorage.setItem(BC_LS, JSON.stringify(_bcSettings)); } catch (e) {} }
const _bcControllers = new Set();
function _bcApplyAll() { _bcControllers.forEach((c) => { try { c.applySettings(); } catch (e) {} }); }
// Live-apply hook for the plugin's settings.html. The visualizer's on/off +
// slider controls now live in the standard settings panel (settings.html),
// which persists them into the BC_LS blob and then calls this so a mounted
// highway re-reads and applies them immediately. Assigned at module scope
// (R5: h3d-carve-4 — 1 beyond-subst vs the IIFE placement; body verbatim)
// so it is available before the IIFE runs. settings.html guards with `?.`.
window.h3dBcApplySettings = function () {
_bcSettings = null; // drop the cache so the next read reloads from localStorage
_bcLoadSettings();
_bcApplyAll();
try { _bcUpdatePanelPreset(); } catch (e) {}
};
// Preset curation: favorites / bans (persisted globally) + the "primary"
// controller the panel's preset buttons drive.
// Seeded once on first run (reputation-based starter set; user can edit freely).
const BC_DEFAULT_FAVORITES = [
'Flexi, martin + geiss - dedicated to the sherwin maxawow',
'Geiss - Reaction Diffusion 2',
'Geiss - Spiral Artifact',
'Flexi + Martin - cascading decay swing',
'Flexi - mindblob [shiny mix]',
'Geiss - Cauldron - painterly 2 (saturation remix)',
'Zylot - Paint Spill (Music Reactive Paint Mix)',
'Flexi - predator-prey-spirals',
'Rovastar + Loadus + Geiss - FractalDrop (Triple Mix)',
'Flexi, fishbrain, Geiss + Martin - tokamak witchery',
];
const BC_DEFAULT_BANS = [
'martin - mucus cervix',
'Goody - The Wild Vort',
'martin - extreme heat',
'Unchained - Rewop',
'high-altitude basket unraveling - singh grooves nitrogen argon nz+',
'$$$ Royal - Mashup (197)',
'$$$ Royal - Mashup (431)',
'suksma - uninitialized variabowl (hydroponic chronic)',
'shifter - dark tides bdrv mix 2',
'_Mig_049',
];
const _bcFavorites = new Set();
const _bcBanned = new Set();
let _bcListsLoaded = false;
function _bcLoadLists() {
if (_bcListsLoaded) return; _bcListsLoaded = true;
try { (JSON.parse(localStorage.getItem('viz3d_favorites') || '[]') || []).forEach((n) => _bcFavorites.add(n)); } catch (e) {}
try { (JSON.parse(localStorage.getItem('viz3d_banned') || '[]') || []).forEach((n) => _bcBanned.add(n)); } catch (e) {}
let seeded = false;
try { seeded = !!localStorage.getItem('viz3d_seeded'); } catch (e) {}
if (!seeded) {
BC_DEFAULT_FAVORITES.forEach((n) => _bcFavorites.add(n));
BC_DEFAULT_BANS.forEach((n) => _bcBanned.add(n));
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
_bcSaveLists();
}
}
function _bcSaveLists() {
try { localStorage.setItem('viz3d_favorites', JSON.stringify([..._bcFavorites])); } catch (e) {}
try { localStorage.setItem('viz3d_banned', JSON.stringify([..._bcBanned])); } catch (e) {}
}
// Re-add the bundled defaults anytime (merges; a default-fav un-bans, a default-ban un-favs).
function _bcRestoreDefaults() {
BC_DEFAULT_FAVORITES.forEach((n) => { _bcBanned.delete(n); _bcFavorites.add(n); });
BC_DEFAULT_BANS.forEach((n) => { _bcFavorites.delete(n); _bcBanned.add(n); });
try { localStorage.setItem('viz3d_seeded', '1'); } catch (e) {}
_bcSaveLists(); _bcUpdatePanelPreset(); _bcRenderList();
}
let _bcPrimary = null;
let _bcPane = null, _bcListEl = null, _bcFilterEl = null, _bcPaneOpen = false, _bcCollapsed = false;
function _bcStatusMark(name) {
return _bcFavorites.has(name) ? '★ ' : (_bcBanned.has(name) ? '🚫 ' : '');
}
// Hoisted above the functions that reference it so no-use-before-define
// does not flag closure reads inside _bcSetHold, _bcLayout, _bcSetPane,
// _bcUpdatePanelPreset. All are closures — _bcPanel is read at call time,
// not at module-evaluation time. Original declaration was at line ~316.
let _bcPanel = null, _bcPanelKeyBound = false;
function _bcSetHold(v) {
const s = _bcLoadSettings();
s.hold = !!v; _bcSaveSettings();
const b = _bcPanel && _bcPanel.querySelector('#vz-hold');
if (b) b.textContent = s.hold ? '▶ Resume' : '⏸ Hold';
}
// Drives both panels off the right edge. Order when both open (L→R):
// visualizer panel → preset pane → window edge. Pane lives off-screen by
// default; opening it shoves the panel LEFT to make room.
function _bcLayout() {
if (_bcPanel) {
let tx = 0;
if (_bcCollapsed) tx = 210; // tuck the whole panel off the right edge
else if (_bcPaneOpen) tx = -248; // slide panel LEFT to make room for the pane
_bcPanel.style.transform = 'translateX(' + tx + 'px) translateY(-50%)';
}
if (_bcPane) {
_bcPane.style.transform = (_bcPaneOpen && !_bcCollapsed) ? 'translateX(0) translateY(-50%)' : 'translateX(calc(100% + 16px)) translateY(-50%)';
}
}
function _bcSetPane(open) {
_bcPaneOpen = !!open && !_bcCollapsed;
const b = _bcPanel && _bcPanel.querySelector('#vz-listbtn');
if (b) b.textContent = _bcPaneOpen ? '>>' : '<<';
if (_bcPaneOpen) _bcRenderList();
_bcLayout();
}
function _bcRenderList() {
if (!_bcListEl) return;
const ctrl = _bcPrimary;
const keys = (ctrl && ctrl.keys) ? ctrl.keys : [];
const filt = ((_bcFilterEl && _bcFilterEl.value) || '').toLowerCase();
const cur = ctrl && ctrl.curName;
const frag = document.createDocumentFragment();
for (let i = 0; i < keys.length; i++) {
const name = keys[i];
if (filt && name.toLowerCase().indexOf(filt) === -1) continue;
const row = document.createElement('div');
row.textContent = _bcStatusMark(name) + name;
row.title = name;
row.style.cssText = 'padding:3px 7px;border-radius:4px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px;' +
(name === cur ? 'background:rgba(110,160,255,.28);' : '') + (_bcBanned.has(name) ? 'opacity:.55;' : '');
row.addEventListener('click', () => {
if (!_bcPrimary) return;
_bcPrimary.loadByName(name, 1.0);
_bcSetHold(true); // picked from the list → sit on it
});
frag.appendChild(row);
}
_bcListEl.innerHTML = '';
_bcListEl.appendChild(frag);
}
function _bcUpdatePanelPreset() {
if (!_bcPanel) return;
const name = _bcPrimary ? (_bcPrimary.curName || null) : null;
const nameEl = _bcPanel.querySelector('#vz-pname');
const favBtn = _bcPanel.querySelector('#vz-fav');
const banBtn = _bcPanel.querySelector('#vz-ban');
const cntEl = _bcPanel.querySelector('#vz-pcount');
if (nameEl) { nameEl.textContent = (name ? _bcStatusMark(name) : '') + (name || '—'); nameEl.title = name ? (name + ' — click for full list') : ''; }
if (favBtn) favBtn.textContent = (name && _bcFavorites.has(name)) ? '★ Favorited' : '☆ Favorite';
if (banBtn) banBtn.textContent = (name && _bcBanned.has(name)) ? '🚫 Banned' : '🚫 Ban';
if (cntEl) cntEl.textContent = '★ ' + _bcFavorites.size + ' 🚫 ' + _bcBanned.size;
if (_bcPaneOpen) _bcRenderList();
}
// _bcPanel hoisted to before _bcSetHold — see comment there.
function _bcEnsurePanel(host) {
if (_bcPanel && _bcPanel.isConnected) {
// Singleton panel: follow the active highway. If it's still parented
// to a different wrap (e.g. another mounted highway instance such as
// Virtuoso's embedded one), move it — and the pane — to this wrap so
// it appears on whichever highway is currently on-screen.
if (host && _bcPanel.parentNode !== host) {
host.appendChild(_bcPanel);
if (_bcPane) host.appendChild(_bcPane);
}
return _bcPanel;
}
const s = _bcLoadSettings();
const p = document.createElement('div');
p.id = 'viz3d-panel';
p.style.cssText = 'position:absolute;top:50%;right:10px;z-index:100000;pointer-events:auto;font:12px/1.45 system-ui,sans-serif;' +
'color:#cfe3ff;background:rgba(8,10,20,0.82);padding:9px 11px;border-radius:8px;width:186px;' +
'box-shadow:0 2px 12px rgba(0,0,0,0.5);user-select:none;transition:transform 0.28s ease;';
p.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:7px"><span style="font-weight:600">🌀 Visualizer</span><button id="vz-listbtn" title="Show / hide full preset list" style="' + BC_BTN + ';padding:1px 7px">&lt;&lt;</button></div>' +
// On/off + opacity/dim/chart/tint/gain controls now live in the
// plugin's Settings panel (settings.html). This in-canvas panel is
// only the LIVE preset browser (pick / favorite / ban / cycle).
'<div style="opacity:.55;font-size:11px;margin:2px 0 6px">Background &amp; reactivity options are in Settings ▸ 3D Highway.</div>' +
'<div style="display:flex;align-items:center;gap:6px;margin:4px 0">' +
'<button id="vz-prev" style="' + BC_BTN + '">◀</button>' +
'<div id="vz-pname" style="flex:1;text-align:center;font-size:11px;opacity:.9;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" title="">—</div>' +
'<button id="vz-next" style="' + BC_BTN + '">▶</button>' +
'</div>' +
'<div style="display:flex;gap:6px;margin:4px 0">' +
'<button id="vz-fav" style="' + BC_BTN + ';flex:1">♡ Favorite</button>' +
'<button id="vz-ban" style="' + BC_BTN + ';flex:1">🚫 Ban</button>' +
'</div>' +
'<div style="display:flex;gap:6px;align-items:flex-end;margin:6px 0">' +
'<label style="flex:1">Cycle <select id="vz-cyc" style="width:100%;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;padding:3px"><option value="all">All</option><option value="favorites">Favorites</option><option value="bans">Bans</option></select></label>' +
'<button id="vz-hold" style="' + BC_BTN + '">⏸ Hold</button>' +
'</div>' +
'<div style="margin:5px 0 4px;font-size:11px;opacity:.75"><span id="vz-pcount">★ 0 🚫 0</span></div>' +
'<div id="vz-meter" style="opacity:.65;margin-top:6px;font:11px/1.3 monospace">gtr — · song —</div>' +
'<div style="opacity:.45;margin-top:4px;font-size:11px">` or ‹‹ to hide</div>';
(host || document.body).appendChild(p);
// Slide handle (<< / >>) so the panel can tuck off the right edge and stop
// covering the Now / Up-Next labels.
const tab = document.createElement('button');
tab.textContent = '>>';
tab.title = 'Hide / show controls';
tab.style.cssText = 'position:absolute;top:6px;left:-23px;width:23px;height:28px;border:none;cursor:pointer;' +
'background:rgba(8,10,20,0.82);color:#cfe3ff;border-radius:7px 0 0 7px;font:12px/1 monospace;padding:0;';
p.appendChild(tab);
tab.addEventListener('click', () => {
_bcCollapsed = !_bcCollapsed;
if (_bcCollapsed) _bcPaneOpen = false; // collapsing the panel hides the pane too
tab.textContent = _bcCollapsed ? '<<' : '>>';
const lb = p.querySelector('#vz-listbtn'); if (lb) lb.textContent = _bcPaneOpen ? '>>' : '<<';
_bcLayout();
});
// Sliding preset-list pane (sits to the LEFT of the control panel)
const pane = document.createElement('div');
pane.id = 'viz3d-listpane';
pane.style.cssText = 'position:absolute;top:50%;right:10px;z-index:99999;pointer-events:auto;width:236px;max-height:74vh;display:flex;flex-direction:column;' +
'background:rgba(8,10,20,0.93);border-radius:8px;box-shadow:0 2px 14px rgba(0,0,0,0.55);color:#cfe3ff;' +
'font:12px system-ui,sans-serif;overflow:hidden;transform:translateX(calc(100% + 16px)) translateY(-50%);transition:transform 0.28s ease;';
pane.innerHTML =
'<div style="display:flex;align-items:center;justify-content:space-between;padding:7px 9px 7px 10px;font-weight:600;border-bottom:1px solid rgba(255,255,255,.1)"><span>Presets</span><button id="vz-defaults" title="Restore the bundled default favorites + bans" style="' + BC_BTN + ';font-weight:400">↺ defaults</button></div>' +
'<input id="vz-filter" placeholder="filter…" spellcheck="false" style="margin:8px 9px 6px;padding:4px 7px;background:#11141f;color:#cfe3ff;border:1px solid rgba(255,255,255,.15);border-radius:5px;outline:none">' +
'<div id="vz-list" style="overflow-y:auto;padding:0 4px 8px"></div>';
(host || document.body).appendChild(pane);
_bcPane = pane;
_bcListEl = pane.querySelector('#vz-list');
_bcFilterEl = pane.querySelector('#vz-filter');
_bcFilterEl.addEventListener('input', _bcRenderList);
pane.querySelector('#vz-defaults').addEventListener('click', _bcRestoreDefaults);
const q = (id) => p.querySelector(id);
_bcPanel = p;
// Preset curation wiring (favorites / bans / cycle / reset)
_bcLoadLists();
const cyc = q('#vz-cyc');
cyc.value = s.cyclePool || 'all';
// Read fresh: settings.html writes can replace _bcSettings, so the `s`
// captured at panel creation may be stale by the time this fires.
cyc.addEventListener('change', () => { _bcLoadSettings().cyclePool = cyc.value; _bcSaveSettings(); });
_bcSetHold(!!s.hold); // sync the Hold button label to the saved state
q('#vz-hold').addEventListener('click', () => _bcSetHold(!_bcLoadSettings().hold));
q('#vz-listbtn').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
q('#vz-pname').addEventListener('click', () => _bcSetPane(!_bcPaneOpen));
q('#vz-prev').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(-1); });
q('#vz-next').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.step(1); });
q('#vz-fav').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.toggleFav(); });
q('#vz-ban').addEventListener('click', () => { if (_bcPrimary) _bcPrimary.banCur(); });
_bcSetPane(false); // start collapsed; sets the list-button label
_bcUpdatePanelPreset();
// Live level readout — proves the song (not just guitar) is driving things.
// Self-stops when the panel is removed (_bcPanel !== p).
(function meterLoop() {
if (_bcPanel !== p) return;
const m = p.querySelector('#vz-meter');
if (m) m.textContent = 'gtr ' + _bcMeters.gtr.toFixed(2) + ' · song ' + _bcMeters.song.toFixed(2);
setTimeout(meterLoop, 150);
})();
if (!_bcPanelKeyBound) {
_bcPanelKeyBound = true;
window.addEventListener('keydown', (e) => {
if (e.key !== '`' || e.metaKey || e.ctrlKey || !_bcPanel) return;
const tag = (e.target && e.target.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const reveal = _bcPanel.style.display === 'none';
_bcPanel.style.display = reveal ? '' : 'none';
if (_bcPane) _bcPane.style.display = reveal ? '' : 'none';
});
}
return _bcPanel;
}
// Create a Butterchurn background controller bound to a wrap element.
export function _bcCreateController(wrap, sizeProvider, audioProvider) {
const ctrl = { viz: null, actx: null, guitar: null, map: null, keys: [], cycle: 0, dead: false, lastW: -1, lastH: -1, canvas: null, backdrop: null, scrim: null, tint: null, wrap: wrap };
// Layered DOM in the wrap, all BEHIND the transparent 3D highway:
// backdrop(z-4 dark) → bc canvas(z-3) → tint(z-2 instrument color) → scrim(z-1 lane dim)
const mkLayer = (cls, css) => { const d = document.createElement('div'); d.className = cls; d.style.cssText = css; wrap.appendChild(d); return d; };
const backdrop = mkLayer('viz3d-backdrop', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-4;background:#070710;pointer-events:none;');
const canvas = document.createElement('canvas');
canvas.className = 'viz3d-bc';
canvas.style.cssText = 'position:absolute;top:0;left:0;z-index:-3;pointer-events:none;';
wrap.appendChild(canvas);
const tint = mkLayer('viz3d-tint', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-2;pointer-events:none;mix-blend-mode:overlay;background:transparent;');
const scrim = mkLayer('viz3d-scrim', 'position:absolute;top:0;left:0;right:0;bottom:0;z-index:-1;pointer-events:none;');
ctrl.canvas = canvas; ctrl.backdrop = backdrop; ctrl.scrim = scrim; ctrl.tint = tint;
ctrl.applySettings = function () {
const s = _bcLoadSettings();
canvas.style.display = s.enabled ? '' : 'none';
canvas.style.opacity = String(s.enabled ? s.opacity : 0);
if (s.laneDim) {
const a = Math.max(0, Math.min(1, s.laneDimStrength)).toFixed(3);
scrim.style.display = '';
scrim.style.background = 'linear-gradient(to right, rgba(0,0,0,0) 0%, rgba(0,0,0,' + a +
') 30%, rgba(0,0,0,' + a + ') 70%, rgba(0,0,0,0) 100%)';
} else {
scrim.style.display = 'none';
}
};
// ── Preset curation (favorites / bans / cycle mode) ──
ctrl.curName = null; ctrl.lastManual = 0;
ctrl.allList = () => (ctrl.keys || []).filter((k) => !_bcBanned.has(k));
ctrl.pool = () => {
const mode = _bcLoadSettings().cyclePool || 'all';
if (mode === 'bans') return (ctrl.keys || []).filter((k) => _bcBanned.has(k));
if (mode === 'favorites') {
const f = (ctrl.keys || []).filter((k) => _bcFavorites.has(k) && !_bcBanned.has(k));
if (f.length) return f;
}
return ctrl.allList();
};
ctrl.browseArr = () => ctrl.keys || []; // ◀▶ and the list pane walk the full preset list
ctrl.loadByName = (name, blend) => {
if (!ctrl.viz || !name || !ctrl.map || !ctrl.map[name]) return;
try { ctrl.viz.loadPreset(ctrl.map[name], blend || 0); ctrl.curName = name; } catch (e) {}
_bcUpdatePanelPreset();
};
ctrl.autoTick = () => {
if (ctrl.dead || _bcLoadSettings().hold) return;
if (performance.now() - ctrl.lastManual < 8000) return;
const pool = ctrl.pool();
if (!pool.length) return;
let name = pool[(Math.random() * pool.length) | 0];
if (pool.length > 1 && name === ctrl.curName) name = pool[(pool.indexOf(name) + 1) % pool.length];
ctrl.loadByName(name, 2.7);
};
ctrl.step = (dir) => {
const list = ctrl.browseArr();
if (!list.length) return;
let i = list.indexOf(ctrl.curName); if (i < 0) i = (dir > 0 ? -1 : 0);
i = (i + dir + list.length) % list.length;
ctrl.lastManual = performance.now();
ctrl.loadByName(list[i], 1.5);
};
ctrl.toggleFav = () => {
if (!ctrl.curName) return;
if (_bcFavorites.has(ctrl.curName)) _bcFavorites.delete(ctrl.curName);
else { _bcFavorites.add(ctrl.curName); _bcBanned.delete(ctrl.curName); }
_bcSaveLists(); _bcUpdatePanelPreset();
};
ctrl.banCur = () => {
if (!ctrl.curName) return;
if (_bcBanned.has(ctrl.curName)) { // un-ban (two-way) — stay on it
_bcBanned.delete(ctrl.curName);
_bcSaveLists(); _bcUpdatePanelPreset();
} else { // ban + advance off it
_bcBanned.add(ctrl.curName); _bcFavorites.delete(ctrl.curName);
_bcSaveLists(); ctrl.step(1);
}
};
_bcPrimary = ctrl;
_bcControllers.add(ctrl);
_bcEnsurePanel(wrap);
ctrl.applySettings();
_bcLoadLib().then(() => {
if (ctrl.dead) return;
const bc = _bcResolve();
if (!bc || typeof bc.createVisualizer !== 'function') { console.warn('[viz3d] Butterchurn global missing'); return; }
const Ctx = window.AudioContext || window.webkitAudioContext;
const sz = (sizeProvider && sizeProvider()) || { w: 1280, h: 720 };
// Browser (Docker/web app): REUSE the highway's existing shared
// analyser (the fog scenery's #audio / stems tap) via audioProvider,
// and build Butterchurn on that SAME AudioContext so connectAudio()
// doesn't fail cross-context. Desktop uses its own context fed by the
// guitar/mic input. `ownsActx` tracks whether WE created the context
// (so destroy() closes only contexts we own, never the shared one).
const fogAudio = _bcIsDesktop() ? null : (audioProvider ? audioProvider() : null);
ctrl.ownsActx = !(fogAudio && fogAudio.ctx);
ctrl.actx = (fogAudio && fogAudio.ctx) || new Ctx();
if (ctrl.actx.state === 'suspended' && ctrl.actx.resume) ctrl.actx.resume().catch(() => {});
// Seed the DRAWING BUFFER (canvas.width/height) to the device-pixel
// render size and report that SAME size to Butterchurn. Its on-screen
// pass viewports to the reported size but never sizes the output canvas
// itself — leaving the buffer at the 300x150 default blits the whole
// visualizer into a corner that CSS then stretches across the highway.
// pixelRatio:1 because DPR is now folded into the reported size, so
// buffer == viewport == internal texsize (no double-counting).
const _bcRatio0 = Math.min(window.devicePixelRatio || 1, 1.5);
const _bcW0 = Math.max(1, Math.round((sz.w || 1280) * _bcRatio0));
const _bcH0 = Math.max(1, Math.round((sz.h || 720) * _bcRatio0));
canvas.width = _bcW0; canvas.height = _bcH0;
ctrl.viz = bc.createVisualizer(ctrl.actx, canvas, {
width: _bcW0, height: _bcH0,
pixelRatio: 1, textureRatio: 1,
});
if (_bcIsDesktop()) {
try {
ctrl.guitar = _bcGuitarFeed(ctrl.actx, (srcNode) => { try { if (ctrl.viz) ctrl.viz.connectAudio(srcNode); } catch (e) {} });
console.log('[viz3d] bg: feeding GUITAR input into Butterchurn');
} catch (e) { console.warn('[viz3d] guitar feed failed', e); }
} else if (fogAudio && fogAudio.analyser) {
// The shared AnalyserNode is a passthrough — connecting it onward
// to Butterchurn's internal analyser doesn't disturb the fog's reads.
try { ctrl.viz.connectAudio(fogAudio.analyser); console.log('[viz3d] browser: Butterchurn tapping shared analyser (' + (fogAudio.source || 'core') + ')'); }
catch (e) { console.warn('[viz3d] shared-analyser connect failed', e); }
}
_bcLoadLists();
const presets = _bcPresets();
if (presets && typeof presets.getPresets === 'function') { ctrl.map = presets.getPresets(); ctrl.keys = Object.keys(ctrl.map); }
const pool0 = ctrl.pool();
ctrl.loadByName(pool0.length ? pool0[(Math.random() * pool0.length) | 0] : (ctrl.keys[0] || null), 0.0);
ctrl.cycle = setInterval(() => ctrl.autoTick(), 30000);
ctrl.connectedAnalyser = (fogAudio && fogAudio.analyser) || null;
console.log('[viz3d] Butterchurn ready, presets:', ctrl.keys.length);
}).catch((e) => {
// Async init failed (lib load, WebGL/context creation, etc.). Clean up
// the half-mounted controller so we don't leak an owned AudioContext /
// DOM layers, and mark it dead so _bcSyncMode can retry on a later
// mount instead of seeing a live-looking but non-functional bcCtrl.
console.error('[viz3d] Butterchurn load/init failed', e);
try { _bcReleaseCanvasGL(ctrl.canvas); } catch (_) {}
try { if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; } } catch (_) {}
try { [ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); }); } catch (_) {}
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') { try { ctrl.actx.close(); } catch (_) {} }
ctrl.actx = null; ctrl.viz = null; ctrl.dead = true;
_bcControllers.delete(ctrl);
});
// Size the Butterchurn output: set the canvas DRAWING BUFFER to the
// device-pixel render size AND report that same size, so buffer ==
// on-screen viewport == full fill. Butterchurn never sizes the output
// canvas itself; the previous code set only CSS size, leaving the buffer
// at the 300x150 default -> the viz showed a stretched lower-left corner
// (worse the larger the panel). Ratio reuses the highway's DPR budget.
function _bcApplySize(cssW, cssH) {
if (!(cssW > 0 && cssH > 0)) return;
ctrl.lastW = cssW; ctrl.lastH = cssH;
const ratio = Math.min(window.devicePixelRatio || 1, 1.5);
const bw = Math.max(1, Math.round(cssW * ratio)), bh = Math.max(1, Math.round(cssH * ratio));
if (canvas.width !== bw) canvas.width = bw;
if (canvas.height !== bh) canvas.height = bh;
const wpx = cssW + 'px', hpx = cssH + 'px';
// Confine ALL layers to exactly the highway-canvas rect so the opaque
// backdrop can't bleed over the transport bar above the highway.
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => {
if (el) { el.style.width = wpx; el.style.height = hpx; el.style.right = 'auto'; el.style.bottom = 'auto'; }
});
if (ctrl.viz && ctrl.viz.setRendererSize) { try { ctrl.viz.setRendererSize(bw, bh); } catch (e) {} }
}
return {
applySettings() { ctrl.applySettings(); },
dead() { return ctrl.dead; },
ready() { return !!ctrl.viz; },
boundAnalyser() { return ctrl.connectedAnalyser || null; },
audioCtx() { return ctrl.actx; },
// Re-bind audio when the shared analyser changes (e.g. a stems song
// swap replaces the analyser). Same context → cheap reconnect; the
// caller handles a context change with a full rebuild (cross-context
// connectAudio is impossible — the visualizer is bound to one ctx).
reconnectAudio(a) {
if (!a || !a.analyser || !ctrl.viz) return false;
if (a.analyser === ctrl.connectedAnalyser) return true;
if (a.ctx && a.ctx !== ctrl.actx) return false; // needs rebuild
try { ctrl.viz.connectAudio(a.analyser); ctrl.connectedAnalyser = a.analyser; return true; } catch (e) { return false; }
},
chart(v) { if (ctrl.guitar && ctrl.guitar.setChart) ctrl.guitar.setChart(v); },
tint(hex, alpha) {
if (!ctrl.tint) return;
if (hex == null) { ctrl.tint.style.background = 'transparent'; return; }
const r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
ctrl.tint.style.background = 'rgba(' + r + ',' + g + ',' + b + ',' + (alpha || 0).toFixed(3) + ')';
},
render() {
const s = _bcLoadSettings();
if (!ctrl.viz || !s.enabled) return; // skip GPU work when the bg is off
const sz = sizeProvider && sizeProvider();
if (sz && sz.w > 0 && sz.h > 0 && (sz.w !== ctrl.lastW || sz.h !== ctrl.lastH)) {
_bcApplySize(sz.w, sz.h);
}
try { ctrl.viz.render(); } catch (e) {}
},
resize(w, h) { _bcApplySize(w, h); },
destroy() {
ctrl.dead = true;
_bcControllers.delete(ctrl);
if (_bcPrimary === ctrl) { _bcPrimary = _bcControllers.values().next().value || null; _bcUpdatePanelPreset(); }
if (ctrl.cycle) { clearInterval(ctrl.cycle); ctrl.cycle = 0; }
if (ctrl.guitar) { ctrl.guitar.stop(); ctrl.guitar = null; }
// Release the Butterchurn WebGL context deterministically (don't
// wait for GC) so repeated mounts/toggles can't exhaust the
// browser's WebGL context cap (~16). Do it before removing the
// canvas from the DOM.
_bcReleaseCanvasGL(ctrl.canvas);
[ctrl.canvas, ctrl.backdrop, ctrl.scrim, ctrl.tint].forEach((el) => { if (el && el.parentNode) el.parentNode.removeChild(el); });
ctrl.viz = null; ctrl.connectedAnalyser = null;
// Close the AudioContext only if we own it (desktop, or the
// browser fallback). The browser path normally reuses the
// highway's shared context, which the fog system owns — never
// close that. Without this, desktop leaks a new AudioContext per
// mount and hits the browser's ~6-context cap after a few toggles.
if (ctrl.ownsActx && ctrl.actx && typeof ctrl.actx.close === 'function') {
try { ctrl.actx.close(); } catch (e) {}
}
ctrl.actx = null;
if (_bcControllers.size === 0) {
if (_bcPanel && _bcPanel.parentNode) _bcPanel.parentNode.removeChild(_bcPanel);
if (_bcPane && _bcPane.parentNode) _bcPane.parentNode.removeChild(_bcPane);
_bcPanel = null; _bcPane = null; _bcListEl = null; _bcFilterEl = null; _bcPaneOpen = false;
} else if (_bcPrimary && _bcPrimary.wrap) {
// Splitscreen: a controller other than this one is still
// alive. The singleton panel was parented to THIS (now
// destroyed) wrap, so re-home it onto the surviving primary's
// wrap — otherwise the panel is orphaned on the dead wrap and
// the surviving highway is left with no visualizer controls
// (_bcEnsurePanel only runs at controller creation). It moves
// the existing panel+pane when connected, or rebuilds them on
// the survivor if this wrap was already detached.
try { _bcEnsurePanel(_bcPrimary.wrap); _bcUpdatePanelPreset(); } catch (e) {}
}
},
};
}
-441
View File
@@ -1,441 +0,0 @@
// h3d-carve-5: player-chrome background control
//
// Verbatim move of the H-section from screen.js (_pc* symbols, lines 3057-3476
// pre-cut). IIFE-scope dependencies injected via factory DI so the module has
// no side-effects at import time.
//
// Beyond-subst (2):
// 1. Factory wrapper — closure vars become DI params.
// 2. `_venueSceneOverride` → `getVenueSceneOverride()` (live accessor, 1 call
// site in _pcSync at what was screen.js:3220).
//
// screen.js usage:
// import { createBgControl } from './src/bg-control.js'; // h3d-carve-5
// const { _pcAcquire, _pcRelease } = createBgControl({
// BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,
// getVenueSceneOverride: () => _venueSceneOverride,
// });
export function createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe, getVenueSceneOverride }) {
/* ======================================================================
* Player-chrome background control
* ======================================================================
* A Background picker mounted into the player's Plugins rail popover, so
* the background can be switched MID-SONG without leaving for Settings.
*
* It writes through the SAME global setters settings.html uses
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
* is persisted here, and the option list is generated from BG_STYLE_IDS
* add a style there and it shows up in both places automatically.
*
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
* instances but these settings are global a panel may set a per-panel
* override, but this single shared control only ever reads/writes the
* global slot (via _bgReadGlobal), so N copies would be N ways to set
* one value. init() acquires, destroy() releases,
* and the last release unmounts so the control disappears when the user
* switches to a non-3D renderer instead of lingering as a dead knob.
*
* Everything here is event-driven. No DOM work on a per-frame path.
*/
// Wording is kept verbatim in sync with settings.html's <option> text so
// the same style is not named two different things in two UIs that sit
// two clicks apart. An id with no entry here falls back to the raw id.
const _PC_LABELS = {
off: 'Off', particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image', video: 'Custom video',
};
// Which settings each background style actually consumes, so a control
// that would do nothing is greyed out instead of lying.
//
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
// build() reads settings.intensity, and uses `reactive` if its update()
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
// drives its own audio tap and canvas opacity (only the fog-scenery half
// falls through to BG_STYLES.off). So neither knob here reaches it - both
// are false, and the tooltip points at Butterchurn's own controls.
//
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
// and its row is not updated, the control stays greyed out and lies the
// other way. An id missing from this table defaults to both-enabled, which
// is the safe direction: a new style is assumed to use its settings.
const _PC_USES = {
off: { intensity: false, reactive: false, why: 'No background to adjust' },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
// active it is the EFFECTIVE style, so both knobs drive nothing.
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
};
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
// Non-disabled wrappers around the two greyable controls. A native-disabled
// <button>/<input> receives no pointer events, so its `title` tooltip never
// shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host
// that does not provide it gets no control (and no error) - the Settings
// page remains the way in.
function _pcSlot() {
try {
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
// precedent). The playerControlSlot typeof check below already
// covers the practical case - only v3 exposes it - but the
// documented checklist asks plugins to detect v3 explicitly.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
return typeof fn === 'function' ? fn() : null;
} catch (_) { return null; }
}
// Visual language: these controls sit in the player's Plugin Controls
// popover alongside pills from other plugins (Invert, Split, Tuner, the
// STEMS group...), so they follow the same look - small rounded pills,
// dark fill, brighter on hover, tinted when active.
//
// Styled INLINE rather than with the Tailwind classes those plugins use
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
// compiled stylesheet and several of those utilities are not in it, so
// using them would mean regenerating assets/plugin.css and bumping the
// manifest version. The values below are the resolved tokens from
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
// #d1d5db), so the result matches without the build step.
const _PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500 (inert controls)
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
onText: '#86efac', // text-green-300
};
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'transition:background-color .15s,color .15s;';
function _pcPill(label, title) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (title) b.title = title;
b.style.cssText = _PC_PILL;
// Hover is a pseudo-class we cannot express inline; these two
// listeners reproduce hover:bg-dark-500 for non-active pills only
// (an active pill keeps its tint on hover, as the other plugins do).
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
return b;
}
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
// when the active background style ignores the setting entirely (see
// _pcSync and _PC_USES) - the pill stays visible so the layout
// does not jump, but it is inert and says why on hover.
function _pcPaint(btn, on, disabled, reason) {
btn._on = !!on && !disabled;
btn.disabled = !!disabled;
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
// A toggle button must expose its state, not just its label.
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
// which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : '';
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
btn.style.opacity = disabled ? '.45' : '1';
btn.title = reason || 'React to the audio';
if (disabled) {
btn.style.backgroundColor = _PC_C.idle;
btn.style.color = _PC_C.textDim;
return;
}
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
btn.style.color = on ? _PC_C.onText : _PC_C.text;
}
function _pcGroupLabel(text) {
const el = document.createElement('div');
el.textContent = text;
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
return el;
}
// Pull every control back to what is actually stored. Runs on mount and
// whenever the settings bus reports one of our keys changed, so editing
// from the Settings page updates this control and vice-versa.
function _pcSync() {
// The active style is the EFFECTIVE one, not the stored one: while the
// Venue scene override is on it is what's mounted, and it ignores the
// whole Background group - picking a style writes `style` but
// _bgMountStyle resolves back to venue, so the dropdown would look
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
// and the user exits Venue from the visualization picker where they
// entered it. An unknown id enables everything rather than disabling
// it, so a style added without a _PC_USES row is merely unhelpful.
const venue = !!getVenueSceneOverride(); // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride() // beyond-subst 2: _venueSceneOverride → getVenueSceneOverride()
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
const why = uses.why || 'This background style ignores this setting';
if (_pcReason) _pcReason.textContent = why;
// Point a screen reader at the reason, but only while a control is
// inert - cleared otherwise so an enabled control is not described by a
// stale reason.
const _pcDescribe = (el, inert) => {
if (!el) return;
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
else el.removeAttribute('aria-describedby');
};
_pcDescribe(_pcSel, venue);
_pcDescribe(_pcReactive, !uses.reactive);
_pcDescribe(_pcIntensity, !uses.intensity);
if (_pcSel) {
// The custom slots stay unselectable until something is uploaded -
// same rule settings.html applies.
const img = _pcSel.querySelector('option[value="image"]');
const vid = _pcSel.querySelector('option[value="video"]');
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
_pcSel.value = _bgReadGlobal('style');
// The dropdown still SHOWS the stored style (venue has no option),
// but it's inert while Venue owns the scene.
_pcSel.disabled = venue;
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
_pcSel.style.opacity = venue ? '.45' : '1';
_pcSel.style.cursor = venue ? 'not-allowed' : '';
// Restore the base tooltip when Venue exits — blanking it would
// permanently drop the mount-time 'Background style' hint. Matches
// how the intensity slider and Reactive pill restore theirs.
_pcSel.title = venue ? why : 'Background style';
}
if (_pcReactive) {
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why);
}
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
// enabled so the control's own title takes over.
if (_pcReactiveWrap) {
_pcReactiveWrap.title = uses.reactive ? '' : why;
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
}
if (_pcIntensity) {
_pcIntensity.value = String(_bgReadGlobal('intensity'));
_pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
}
if (_pcIntensityWrap) {
_pcIntensityWrap.title = uses.intensity ? '' : why;
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
}
}
// Mirror the current values into the Settings panel's controls when it's
// in the DOM.
//
// settings.html hydrates ONCE from localStorage when the panel is injected
// and never subscribes to the settings bus, so before this existed there
// was only one writer and it could not go stale. Adding the in-player
// picker made a second writer, and the panel had no way to hear about it —
// change the style mid-song and Settings would still show the old value.
//
// Assigning .value / .checked programmatically does NOT fire a 'change'
// event, so this cannot loop back into the setters.
function _pcSyncSettingsPanel() {
try {
const st = document.getElementById('h3d-bg-style');
if (st) st.value = _bgReadGlobal('style');
const re = document.getElementById('h3d-bg-reactive');
if (re) re.checked = !!_bgReadGlobal('reactive');
const inten = _bgReadGlobal('intensity');
const ie = document.getElementById('h3d-bg-intensity');
if (ie) ie.value = String(inten);
// The panel prints the numeric value beside the slider; keep its
// formatting identical to settings.html's own hydration.
const il = document.getElementById('h3d-bg-intensity-label');
if (il) il.textContent = Number(inten).toFixed(2);
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
}
function _pcMount() {
// A screen change can swap the popover out from under us, orphaning
// the control. Re-resolve only when the cached node is actually gone.
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
if (_pcEl) return true;
const slot = _pcSlot();
if (!slot) return false;
const box = document.createElement('div');
box.className = 'h3d-pc';
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
// Visually-hidden text carrying the "why greyed out" reason to screen
// readers; disabled controls point aria-describedby here. A title alone
// is announced unreliably and never on touch. One span suffices - every
// greyed control shares the same reason (derived from the single
// effective style).
_pcReason = document.createElement('span');
_pcReason.id = 'h3d-pc-reason';
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
box.appendChild(_pcReason);
box.appendChild(_pcGroupLabel('Background'));
// A dropdown, not pills: the style list is 8 entries and growing, and
// a pill per style dominated a popover whose other controls are single
// toggles. Styled to match the surrounding pills rather than left as a
// raw <select>.
_pcSel = document.createElement('select');
_pcSel.title = 'Background style';
_pcSel.setAttribute('aria-label', 'Background style');
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
for (const id of BG_STYLE_IDS) {
const o = document.createElement('option');
o.value = id;
o.textContent = _PC_LABELS[id] || id;
_pcSel.appendChild(o);
}
_pcSel.addEventListener('change', () => {
if (_pcSel.disabled) return; // inert under the Venue override
try { window.h3dBgSetStyle(_pcSel.value); }
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
});
box.appendChild(_pcSel);
const optWrap = document.createElement('div');
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
_pcReactive = _pcPill('Reactive', 'React to the audio');
_pcReactive.addEventListener('click', () => {
if (_pcReactive.disabled) return;
try { window.h3dBgSetReactive(!_pcReactive._on); }
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
});
optWrap.appendChild(_pcReactive);
box.appendChild(optWrap);
box.appendChild(_pcGroupLabel('Intensity'));
// Wrapper carries the reason on hover when the slider is disabled — a
// native-disabled <input> shows no title of its own.
_pcIntensityWrap = document.createElement('div');
_pcIntensityWrap.style.cssText = 'width:100%;';
_pcIntensity = document.createElement('input');
_pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
_pcIntensity.title = 'Background intensity';
_pcIntensity.setAttribute('aria-label', 'Background intensity');
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
// 'change' (fires on release), NOT 'input'. Every write goes through
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
// background style down and re-runs build(). On 'input' a single drag
// across the range would trigger ~20 full scene rebuilds on the main
// thread mid-playback. settings.html's slider makes the same choice:
// oninput only repaints its label, onchange calls the setter.
_pcIntensity.addEventListener('change', () => {
if (_pcIntensity.disabled) return;
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
});
_pcIntensityWrap.appendChild(_pcIntensity);
box.appendChild(_pcIntensityWrap);
slot.appendChild(box);
_pcEl = box;
_pcSync();
_pcListener = (key) => {
if (key === 'style' || key === 'reactive' || key === 'intensity'
|| key === 'customImageDataUrl' || key === 'customVideoName'
|| key === 'venueScene') {
// 'venueScene' has no dropdown/settings widget of its own, but
// toggling Venue changes the EFFECTIVE style, so the greying
// must re-evaluate (see _pcSync's effectiveStyle).
_pcSync();
_pcSyncSettingsPanel();
}
};
_bgSubscribe(_pcListener);
return true;
}
function _pcTeardownDom() {
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
}
function _pcAcquire() {
_pcRefs++;
_pcBindScreenHook();
if (_pcMount()) return;
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
// the renderer is viable inside the v3 player chrome, and player-chrome.js
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
// spinning it out to the ~3s budget for a slot that will never appear.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
// The rail popover may not be built yet on a cold load. Retry a few
// times, then give up quietly — Settings still works.
if (_pcRetryTimer) return;
_pcRetry = 0;
const tick = () => {
_pcRetryTimer = 0;
if (_pcRefs <= 0) return; // renderer went away mid-retry
// Re-attempt the bus subscription too, not just the mount. On a cold
// load the renderer can init before window.feedBack.on exists; the
// first _pcBindScreenHook() then no-ops and, without this, the hook
// never binds and the control goes permanently deaf to screen
// changes. Idempotent via the _pcScreenHook guard.
_pcBindScreenHook();
if (_pcMount()) return;
if (++_pcRetry > 12) return; // ~3s at 250ms
_pcRetryTimer = setTimeout(tick, 250);
};
_pcRetryTimer = setTimeout(tick, 250);
}
// Re-mount after the player chrome is rebuilt.
//
// _pcMount's isConnected check can only run when something calls it, and
// after the first successful mount nothing did - init() and the retry tick
// are the only callers, and the tick stops on success. So a popover that
// got swapped out left the control gone until the next song change. This
// listener gives that check a real trigger.
//
// Event-driven and cheap: one _pcMount() call per screen change, and it
// early-returns immediately when the cached node is still connected.
let _pcScreenHook = null;
function _pcBindScreenHook() {
if (_pcScreenHook) return;
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
try { bus.on('screen:changed', _pcScreenHook); }
catch (e) { _pcScreenHook = null; }
}
function _pcRelease() {
_pcRefs = Math.max(0, _pcRefs - 1);
if (_pcRefs > 0) return;
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
// Drop the screen:changed subscription too, not just the DOM. The
// refcount guard inside the hook makes a stale one harmless, but the
// listener and its closure would otherwise outlive the control for the
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
// again, binding another hook to the same bus while the old one stays.
// _pcBindScreenHook re-binds on the next acquire.
if (_pcScreenHook) {
try {
const bus = window.feedBack;
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
_pcScreenHook = null;
}
_pcTeardownDom();
}
return { _pcAcquire, _pcRelease };
}
-375
View File
@@ -1,375 +0,0 @@
// h3d-carve-9: W-section (camera lerp) — effectiveVfov + camUpdate.
// Cut 13 (S-section lookahead) extends this factory in place: same createCamera({…})
// destructure grows with additional DI params and returned symbols.
//
// DI surface (per §2 of the cut-9 contract):
// Constants (22) — BASE_VFOV, HORPLUS_*, CAM_LERP_BASE, CAM_H/DIST_BASE,
// 6× CAM_FRAME_*, FOCUS_D, S_GAP, K, 3× FRET_ROW_FIT_*,
// 4× CAM_TILT_*
// Getters (11) — getCam, getTgtX/Dist, getAspectScale, getLeftyCached,
// getNStr, getProbe, getTiltSmoothing, getPaneAspect/Uid,
// getHighwayCanvas
// Pairs (5) — getCurX/setCurX, getCurDist/setCurDist,
// getCurLookY/setCurLookY, getTgtLookY/setTgtLookY,
// getFretRowFitBoost/setFretRowFitBoost
// Fn refs (5) — sY, freeCamFor, aspectPaneKey, resolveTuneFor,
// aspectRegisterPane
import { computeBPM, lowerBoundT, camBaseDistU, camLowFretPullbackU } from './geometry.js'; // h3d-carve-1,13
import { _ssActive } from './utils.js'; // h3d-carve-3
export function createCamera({
// ── Constants ──────────────────────────────────────────────────────────
BASE_VFOV, HORPLUS_START_ASPECT, HORPLUS_MIN_VFOV,
CAM_LERP_BASE, CAM_H_BASE, CAM_DIST_BASE,
CAM_FRAME_DIST_NEAR, CAM_FRAME_DIST_FAR,
CAM_FRAME_H_NEAR, CAM_FRAME_H_FAR,
CAM_FRAME_D_NEAR, CAM_FRAME_D_FAR,
FOCUS_D, S_GAP, K,
FRET_ROW_FIT_NDC_MIN, FRET_ROW_FIT_DEADBAND, FRET_ROW_FIT_BOOST_MAX,
CAM_TILT_BAND_T, CAM_TILT_BAND_C, CAM_TILT_STR_T, CAM_TILT_STR_C,
// ── Live-accessor getters ───────────────────────────────────────────────
getCam,
getTgtX, getTgtDist, getAspectScale, getLeftyCached,
getNStr, getProbe, getTiltSmoothing, getPaneAspect, getPaneUid,
getHighwayCanvas,
// ── Getter+setter pairs (write-backs) ──────────────────────────────────
getCurX, setCurX,
getCurDist, setCurDist,
getCurLookY, setCurLookY,
getTgtLookY, setTgtLookY,
getFretRowFitBoost, setFretRowFitBoost,
// ── Function refs ───────────────────────────────────────────────────────
sY, freeCamFor, aspectPaneKey, resolveTuneFor, aspectRegisterPane,
// h3d-carve-13: S-section lookahead — +4 const shorthand, +1 getter, +4 fn refs
NFRETS, CAM_LOOKAHEAD_MEASURES, CAM_LOOKAHEAD_SEC, CAM_FRET_EDGE_BLEND,
getMeasureStarts,
validString, getChartAnchorAt, xFretMid, xFret,
}) {
// Horizontal-FOV-hold ("Hor+"). Returns the vertical fov (deg) the
// camera should use for the given pane aspect. With the bridge off (or
// absent), or at/under the start aspect, it returns the base vertical
// fov unchanged — an exact no-op, so normal panes render identically to
// before. Past the start aspect it lowers the vertical fov to keep the
// horizontal cone ~constant, so the neck fills an ultra-wide pane
// instead of collapsing into a central sliver. Pure + finite-guarded.
function effectiveVfov(aspect, tune) {
// VERBATIM MOVE. 0 beyond-subst: BASE_VFOV / HORPLUS_* / HORPLUS_MIN_VFOV
// are plain DI params in the factory destructure — no rewires needed.
const base = (tune && Number.isFinite(tune.baseVfov)) ? tune.baseVfov : BASE_VFOV;
if (!tune || !tune.enabled || !Number.isFinite(aspect) || aspect <= 0) return base;
const start = (Number.isFinite(tune.startAspect) && tune.startAspect > 0)
? tune.startAspect : HORPLUS_START_ASPECT;
if (aspect <= start) return base;
const floor = Number.isFinite(tune.minVfovDeg) ? tune.minVfovDeg : HORPLUS_MIN_VFOV;
const DEG = Math.PI / 180;
// Held horizontal fov: explicit hfovDeg if given, else the horizontal
// cone the base vertical fov produces at the start aspect.
const hfov = (Number.isFinite(tune.hfovDeg) && tune.hfovDeg > 0)
? tune.hfovDeg * DEG
: 2 * Math.atan(Math.tan(base * DEG / 2) * start);
// Vertical fov that reproduces that horizontal cone at this aspect.
let vfov = 2 * Math.atan(Math.tan(hfov / 2) / aspect) / DEG;
const blend = Number.isFinite(tune.blend) ? Math.max(0, Math.min(1, tune.blend)) : 1;
vfov = base + (vfov - base) * blend; // 0 = base, 1 = full Hor+
if (!Number.isFinite(vfov)) return base;
return Math.max(floor, Math.min(base, vfov));
}
/* ── Camera smooth lerp ──────────────────────────────────────────── */
function camUpdate(bundle) {
// VERBATIM MOVE. DI rewires — 25 beyond-subst:
// Local aliases intro (10): cam, paneAspect, curX, curDist, curLookY,
// tgtLookY, _fretRowFitBoost, nStr, _probe, tiltSmoothing
// Fn-ref renames (4): _aspectPaneKey→aspectPaneKey,
// _aspectRegisterPane→aspectRegisterPane,
// _resolveTuneFor→resolveTuneFor, _freeCamFor→freeCamFor
// Direct getter calls (6): getPaneUid, getTgtX, getTgtDist,
// getAspectScale, getLeftyCached, getHighwayCanvas
// Write-back setter calls (5): setCurX, setCurDist, setCurLookY,
// setTgtLookY, setFretRowFitBoost
const bpm = computeBPM(bundle.beats, bundle.currentTime);
const lerp = CAM_LERP_BASE * Math.max(bpm, 60) / 120;
// ── Horizontal-FOV-hold + optional wide-pane pose nudges ──
// Driven by window.__h3dAspectTune (default off → exact no-op).
// _resolveTuneFor(paneKey) returns the shared base with THIS pane's
// overrides (if any) laid on top, so a single split pane can be framed
// independently. The base is seeded from defaults + localStorage on
// first read, so a persisted tuning session applies on load without
// opening the panel. Every field is finite-coerced. When disabled (or
// splitOnly and not in a split) the tune is treated as null, so
// effectiveVfov returns the base vertical fov and cam.fov is restored
// to it. The fov write is guarded on an actual change so a steady pane
// costs nothing.
const cam = getCam(); // DI rewire: live-accessor
const paneAspect = getPaneAspect(); // DI rewire: live-accessor (replaces _paneAspect)
const _paneKey = aspectPaneKey( // DI rewire: fn-ref rename
bundle && bundle.songInfo && bundle.songInfo.arrangement, getPaneUid()); // DI rewire: getPaneUid
// Only feed the Target-picker registry while the tuner is open (same
// gate as the readout). Closed → nothing is registered, so the registry
// can't grow for users who never open the panel; the key is still
// resolved below so any saved overrides keep applying.
if (window.__h3dAspectPanelOpen) aspectRegisterPane(_paneKey); // DI rewire: fn-ref rename
const _aspTune = resolveTuneFor(_paneKey); // DI rewire: fn-ref rename
const _aspActive = !!(_aspTune && _aspTune.enabled
&& !(_aspTune.splitOnly && !_ssActive()));
const _tune = _aspActive ? _aspTune : null;
const _vfov = effectiveVfov(paneAspect, _tune); // DI rewire: paneAspect
if (Number.isFinite(_vfov) && Math.abs(_vfov - cam.fov) > 1e-4) {
cam.fov = _vfov;
cam.updateProjectionMatrix();
}
// Publish a per-pane live readout for the tuner panel (only while it's
// open, so the steady path stays allocation-free). Keyed by pane so
// the panel can show the reading for whichever target is selected.
if (window.__h3dAspectPanelOpen) {
const _ro = window.__h3dAspectReadout || (window.__h3dAspectReadout = {});
const _slot = _ro[_paneKey] || (_ro[_paneKey] = {});
_slot.aspect = paneAspect; _slot.vfov = _vfov; // DI rewire: paneAspect
_ro.__last = _paneKey;
}
// Optional pose nudges (height / dolly / pitch) to chase a low-flat
// wide-pane look if fov alone isn't enough. Gated to wide panes and
// suppressed while the Camera Director owns the view (it wins).
const _startAspect = (_tune && Number.isFinite(_tune.startAspect) && _tune.startAspect > 0)
? _tune.startAspect : HORPLUS_START_ASPECT;
// Resolve the Camera Director bridge once (per-panel under splitscreen,
// else global). Used both for the wide-pane gate and the transforms below.
const _freeCam = freeCamFor(getHighwayCanvas()); // DI rewire: fn-ref rename + getter
const _dirActive = !!(_freeCam && _freeCam.enabled);
const _wide = !!(_tune && paneAspect > _startAspect) && !_dirActive; // DI rewire: paneAspect
const _poseHMul = (_wide && Number.isFinite(_tune.heightMul)) ? _tune.heightMul : 1;
const _poseDMul = (_wide && Number.isFinite(_tune.distMul)) ? _tune.distMul : 1;
const _poseLookYAdd = (_wide && Number.isFinite(_tune.pitchAdd)) ? _tune.pitchAdd * K : 0;
const _poseLookZMul = (_wide && Number.isFinite(_tune.lookDepthMul) && _tune.lookDepthMul > 0)
? _tune.lookDepthMul : 1;
// DI rewire: lerped state — read per-call into locals, mutate locally,
// write back via setters so screen.js closure vars stay in sync.
// (Never cached at factory init — buildBoard may reset these between frames.)
let curX = getCurX(); // DI rewire: local alias
curX += (getTgtX() - curX) * lerp; // DI rewire: getTgtX()
setCurX(curX); // write-back
let _fretRowFitBoost = getFretRowFitBoost(); // DI rewire: local alias
let curDist = getCurDist(); // DI rewire: local alias
// The fret-row fit guard (end of camUpdate) may dolly the camera back
// via _fretRowFitBoost; the span-driven tgtDist still owns zooming IN.
curDist += (getTgtDist() * _fretRowFitBoost - curDist) * lerp; // DI rewire: getTgtDist()
setCurDist(curDist); // write-back
const dist = curDist * getAspectScale(); // DI rewire: getAspectScale()
const h = CAM_H_BASE * (dist / CAM_DIST_BASE);
// Zoom-interpolated framing multipliers: tight (NEAR) -> lower/closer;
// wide (FAR, fret 1<->20) -> higher/pulled back.
const _zt = Math.max(0, Math.min(1,
(dist - CAM_FRAME_DIST_NEAR) / (CAM_FRAME_DIST_FAR - CAM_FRAME_DIST_NEAR)));
const _hMul = CAM_FRAME_H_NEAR + (CAM_FRAME_H_FAR - CAM_FRAME_H_NEAR) * _zt;
const _dMul = CAM_FRAME_D_NEAR + (CAM_FRAME_D_FAR - CAM_FRAME_D_NEAR) * _zt;
const shoulderOffset = (getLeftyCached() ? -1 : 1) * 10 * K; // DI rewire: getLeftyCached()
let _camX = curX + shoulderOffset, _camY = h * _hMul, _camZ = dist * _dMul;
// Optional wide-pane pose nudges (default identity → no-op).
if (_poseHMul !== 1) _camY *= _poseHMul;
if (_poseDMul !== 1) _camZ *= _poseDMul;
// ── Free-camera user tweaks (orbit / height / zoom / pan) ──
// Driven by the Camera Director plugin via the camera bridge:
// window.__h3dCamCtlPanels[panelIndexFor(canvas)] when split (this
// panel's own camera), falling back to the global window.__h3dCamCtl.
// Layered ON TOP of the auto-framing so note tracking still works.
// The bridge is read once into _freeCam and reused for both the
// position and the look-at transforms; every field is coerced to a
// finite number before use so a malformed object can never feed NaN
// into cam.position / cam.lookAt.
// _freeCam resolved above via freeCamFor(getHighwayCanvas()): the
// per-panel __h3dCamCtlPanels entry, else global __h3dCamCtl, else null.
let curLookY = getCurLookY(); // DI rewire: local alias (read before freeCam block)
const _lookAtZ = -FOCUS_D * 0.35 * _poseLookZMul;
if (_freeCam && _freeCam.enabled) {
const _distMul = Number.isFinite(_freeCam.distMul) ? _freeCam.distMul : 1;
const _heightMul = Number.isFinite(_freeCam.heightMul) ? _freeCam.heightMul : 1;
const _yaw = Number.isFinite(_freeCam.yaw) ? _freeCam.yaw : 0;
const _tx = curX, _ty = curLookY, _tz = _lookAtZ; // look target
let _vx = _camX - _tx, _vy = _camY - _ty, _vz = _camZ - _tz;
_vx *= _distMul; _vy *= _distMul; _vz *= _distMul; // zoom (dolly)
_vy *= _heightMul; // height
const _cy = Math.cos(_yaw), _sy = Math.sin(_yaw); // orbit around Y
const _rx = _vx * _cy - _vz * _sy, _rz = _vx * _sy + _vz * _cy;
_camX = _tx + _rx; _camY = _ty + _vy; _camZ = _tz + _rz;
}
cam.position.set(_camX, _camY, _camZ);
// Self-correcting look-at Y: project the fretboard's near-edge centre
// to NDC space. If it drifts toward the frame edge, nudge tgtLookY
// toward the fretboard centre so the camera tilts to re-frame it.
// This lets the camera adapt to any panel aspect ratio automatically.
const nStr = getNStr(); // DI rewire: local alias
const _probe = getProbe(); // DI rewire: local alias
const fretMidY = (sY(0) + sY(nStr - 1)) / 2;
_probe.set(curX, fretMidY, 0); // play-line fretboard centre
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ); // tentative look — needed for project()
cam.updateMatrixWorld();
_probe.project(cam); // _probe.y → NDC in [-1, 1]
// Keep fretboard centre in the lower third of the screen (NDC ≈ -0.35).
// The deadband width and correction strength are both blended
// between Twitchy and Calm bounds by the user's tiltSmoothing
// setting — twitchy = re-frame aggressively (narrow band, strong
// nudge); calm = let small drift ride (wide band, weak nudge).
const DESIRED_NDC_Y = -0.35;
const tiltSmoothing = getTiltSmoothing(); // DI rewire: local alias
const tiltBand = CAM_TILT_BAND_T + (CAM_TILT_BAND_C - CAM_TILT_BAND_T) * tiltSmoothing;
const tiltStr = CAM_TILT_STR_T + (CAM_TILT_STR_C - CAM_TILT_STR_T) * tiltSmoothing;
let tgtLookY = getTgtLookY(); // DI rewire: local alias
if (_probe.y < DESIRED_NDC_Y - tiltBand || _probe.y > DESIRED_NDC_Y + tiltBand) {
// _probe.y too low → fretboard near bottom → tgtLookY decreases → camera tilts down → fretboard rises
// _probe.y too high → fretboard near top → tgtLookY increases → camera tilts up → fretboard drops
const correction = (DESIRED_NDC_Y - _probe.y) * fretMidY * tiltStr;
tgtLookY = Math.max(-fretMidY, Math.min(fretMidY, tgtLookY - correction));
}
setTgtLookY(tgtLookY); // write-back
curLookY += (tgtLookY - curLookY) * lerp;
setCurLookY(curLookY); // write-back
// Final look-at with the corrected Y (overrides the tentative one above).
// User tilt (pitch) + pan offsets layer on top when the free-cam is
// enabled; each is coerced to a finite number to avoid a NaN look-at.
if (_freeCam && _freeCam.enabled) {
const _panX = Number.isFinite(_freeCam.panX) ? _freeCam.panX : 0;
const _panY = Number.isFinite(_freeCam.panY) ? _freeCam.panY : 0;
const _pitch = Number.isFinite(_freeCam.pitch) ? _freeCam.pitch : 0;
cam.lookAt(curX + _panX * K, curLookY + (_pitch + _panY) * K, _lookAtZ);
} else {
cam.lookAt(curX, curLookY + _poseLookYAdd, _lookAtZ);
}
// ── Fret-row fit guard ────────────────────────────────────────────
// Project the fret-number-row band (just below the lowest string, at
// the play line) with the final camera. If it sits below the safe
// bottom line, dolly back (raise _fretRowFitBoost → applied to the
// curDist lerp target next frame) until it clears; relax lazily once
// there's comfortable headroom. Asymmetric + deadbanded so it
// converges without hunting, and capped so the zoom can't pop. It
// cooperates with the tilt loop above rather than fighting it: pulling
// back shrinks the scene, the tilt loop keeps the board centre anchored
// at DESIRED_NDC_Y, so only the row's bottom headroom changes. Skipped
// while the free-cam (Camera Director) owns the view.
if (_freeCam && _freeCam.enabled) {
if (_fretRowFitBoost !== 1) _fretRowFitBoost = 1;
} else {
cam.updateMatrixWorld();
const _rowY = Math.min(sY(0), sY(nStr - 1)) - S_GAP * 1.4;
_probe.set(curX, _rowY, 0.5 * K);
_probe.project(cam); // _probe.y → NDC; < -1 = off the bottom
const _rowNdcY = _probe.y;
if (_rowNdcY < FRET_ROW_FIT_NDC_MIN) {
// Row below the safe line → pull back promptly, proportional to
// the deficit so it converges in a few frames without overshoot.
const _need = FRET_ROW_FIT_NDC_MIN - _rowNdcY;
_fretRowFitBoost = Math.min(FRET_ROW_FIT_BOOST_MAX,
_fretRowFitBoost + Math.min(0.05, _need * 0.4));
} else if (_rowNdcY > FRET_ROW_FIT_NDC_MIN + FRET_ROW_FIT_DEADBAND
&& _fretRowFitBoost > 1) {
// Comfortable headroom → relax the dolly back toward normal, lazily.
_fretRowFitBoost = Math.max(1, _fretRowFitBoost - 0.01);
}
}
setFretRowFitBoost(_fretRowFitBoost); // write-back
}
/* ── h3d-carve-13: S-section lookahead helpers ───────────────────── */
// VERBATIM MOVE from screen.js Region A (original lines 6630-6719).
// 1 beyond-subst: _measureStarts → getMeasureStarts() (live getter).
// lookaheadEndTime: factory-private (no external callers).
function lookaheadEndTime(now) {
const ms = getMeasureStarts(); // h3d-carve-13: _measureStarts → getMeasureStarts()
if (!ms || ms.length === 0) return now + CAM_LOOKAHEAD_SEC;
// Binary search: lo = first index with ms[lo] > now.
let lo = 0, hi = ms.length;
while (lo < hi) { const mid = (lo + hi) >> 1; if (ms[mid] <= now) lo = mid + 1; else hi = mid; }
const curIdx = lo - 1; // current measure (-1 if before the first)
const targetIdx = curIdx + CAM_LOOKAHEAD_MEASURES;
if (targetIdx >= 0 && targetIdx < ms.length) return ms[targetIdx];
// Past the last measure: extrapolate using the average measure duration.
if (ms.length >= 2) {
const avg = (ms[ms.length - 1] - ms[0]) / (ms.length - 1);
if (avg > 0) return ms[ms.length - 1] + (targetIdx - (ms.length - 1)) * avg;
}
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;
let maxF = 0;
let any = false;
if (anchors && anchors.length) {
for (let tt = now; tt <= tEnd + 1e-9; tt += 0.125) {
const a = getChartAnchorAt(anchors, tt);
if (!a) continue;
let fStart = Math.round(Number(a.fret));
if (!Number.isFinite(fStart) || fStart < 1) fStart = 1;
let w = Number(a.width);
if (!Number.isFinite(w)) w = 4;
w = Math.max(1, Math.round(w));
const fHi = Math.min(NFRETS, fStart + w - 1);
minF = Math.min(minF, fStart);
maxF = Math.max(maxF, fHi);
any = true;
}
}
const consider = f => {
if (!(f > 0)) return;
minF = Math.min(minF, f);
maxF = Math.max(maxF, f);
any = true;
};
if (notes) {
let i = lowerBoundT(notes, now);
for (; i < notes.length; i++) {
const n = notes[i];
if (n.t > tEnd) break;
if (!validString(n.s)) continue;
consider(n.f);
}
}
if (chords) {
let i = lowerBoundT(chords, now);
for (; i < chords.length; i++) {
const ch = chords[i];
if (ch.t > tEnd) break;
if (!ch.notes) continue;
for (const cn of ch.notes) {
if (!validString(cn.s)) continue;
consider(cn.f);
}
}
}
if (!any || minF > maxF) return null;
return { minF, maxF };
}
function lookaheadTargetWorldX(minF, maxF) {
const wb = CAM_FRET_EDGE_BLEND;
const middle = (xFretMid(minF) + xFretMid(maxF)) * 0.5;
const weighted = 0.6 * xFret(0) + 0.4 * xFret(NFRETS);
return middle * (1 - wb) + weighted * wb;
}
return { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX };
}
-149
View File
@@ -1,149 +0,0 @@
// h3d-carve-8: Q-section partial — lighting/FX utilities.
// buildBoard stays in screen.js (deferred to plan §3 row 16, P-section cut)
// because its write-back surface spans 9 factory-scope vars that P/U/Y also
// own; premature extraction would require a ~50-param DI. See plans/highway3d-carve.md.
//
// DI surface (per-call live-accessors — never cache at factory init):
// BG_DEFAULTS, K — plain IIFE-scope constants
// getT — live-accessor (Three.js, lazy-loaded)
// getAmbLight/getDirLight — live-accessors (null until initScene)
// getCinematic/getTimingFx — live-accessors (toggle flags)
// getSparkPts/setSparkPts — live-accessor + setter (Points object, set by buildBoard)
// getSparkN/getSparkPos/… — live-accessors for particle buffers (element-mutated in place)
// getComposer/setComposer — getter+setter (lazy-assigned inside _bloomEnsure)
// getBloomLoad/setBloomLoad — getter+setter (Promise, assigned inside _bloomEnsure)
// getBloomPass/setBloomPass — getter+setter (pass object, assigned inside _bloomEnsure)
// getBloomW/setBloomW/H — getter+setter (dimensions, assigned inside _bloomEnsure)
// getRen/getScene/getCam/getHighwayCanvas — live-accessors (null until initScene)
// canvasSize — factory-scope function ref (stable)
export function createFx({
BG_DEFAULTS, K,
getT,
getAmbLight, getDirLight, getCinematic,
getTimingFx,
getSparkPts, setSparkPts, getSparkN,
getSparkPos, setSparkPos, getSparkVel, setSparkVel,
getSparkCol, setSparkCol, getSparkLife, setSparkLife,
getComposer, setComposer,
getBloomLoad, setBloomLoad,
getBloomPass, setBloomPass,
getBloomW, setBloomW, getBloomH, setBloomH,
getRen, getScene, getCam, getHighwayCanvas,
canvasSize,
}) {
function _h3dHexOrDefault(hexStr, defHex) {
// VERBATIM MOVE. BG_DEFAULTS from plain DI param.
const d = defHex || BG_DEFAULTS.nutColor;
const s = (typeof hexStr === 'string' && /^#[0-9a-fA-F]{6}$/.test(hexStr.trim()))
? hexStr.trim().toLowerCase()
: d;
return parseInt(s.slice(1), 16);
}
// Cinematic lighting (#2): darken ambient so emissive gems have a dark
// surround to pop against; strengthen the key light for modelling.
// Toggle via the 'cinematic' setting so it's directly comparable.
function _applyCinematic() {
// VERBATIM MOVE. DI rewire: ambLight/dirLight/_cinematic from live-accessors.
const ambLight = getAmbLight(), dirLight = getDirLight(), _cinematic = getCinematic();
if (!ambLight || !dirLight) return;
ambLight.intensity = _cinematic ? 0.45 : 0.85;
dirLight.intensity = _cinematic ? 1.15 : 0.8;
}
// #5 early/late: tint the hit feedback by timing — on-time green, early cyan,
// late amber. Falls back to green when timing is unknown (pure-provider path).
function _timingHex(ts) {
// VERBATIM MOVE. DI rewire: _timingFx from getTimingFx().
if (!getTimingFx() || !ts || ts === 'OK') return 0x22ff88;
if (ts === 'EARLY') return 0x35d6ff;
if (ts === 'LATE') return 0xffb84d;
return 0x22ff88;
}
function _sparkBurst(x, y, z, hex, count) {
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call,
// not cached at factory init — buildBoard may reassign the refs).
const _sparkPts = getSparkPts();
if (!_sparkPts || count <= 0) return;
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
const _SPARK_N = getSparkN();
const r = ((hex >> 16) & 255) / 255, g = ((hex >> 8) & 255) / 255, b = (hex & 255) / 255;
let made = 0;
for (let i = 0; i < _SPARK_N && made < count; i++) {
if (_sparkLife[i] > 0) continue;
const j = i * 3, ang = Math.random() * Math.PI * 2, sp = (5 + Math.random() * 12) * K;
_sparkPos[j] = x; _sparkPos[j + 1] = y; _sparkPos[j + 2] = z;
_sparkVel[j] = Math.cos(ang) * sp; _sparkVel[j + 1] = (12 + Math.random() * 24) * K; _sparkVel[j + 2] = Math.sin(ang) * sp * 0.55;
_sparkCol[j] = r; _sparkCol[j + 1] = g; _sparkCol[j + 2] = b;
_sparkLife[i] = 0.30 + Math.random() * 0.16; made++;
}
}
function _sparkUpdate(dt) {
// VERBATIM MOVE. DI rewire: spark vars from live-accessors (per-call).
const _sparkPts = getSparkPts();
if (!_sparkPts) return;
const _sparkPos = getSparkPos(), _sparkVel = getSparkVel(), _sparkCol = getSparkCol(), _sparkLife = getSparkLife();
const _SPARK_N = getSparkN();
const grav = 55 * K; let any = false;
for (let i = 0; i < _SPARK_N; i++) {
if (_sparkLife[i] <= 0) continue;
const j = i * 3;
_sparkLife[i] -= dt;
if (_sparkLife[i] <= 0) { _sparkCol[j] = _sparkCol[j + 1] = _sparkCol[j + 2] = 0; continue; }
any = true;
_sparkVel[j + 1] -= grav * dt;
_sparkPos[j] += _sparkVel[j] * dt; _sparkPos[j + 1] += _sparkVel[j + 1] * dt; _sparkPos[j + 2] += _sparkVel[j + 2] * dt;
const fade = 1 - Math.min(1, dt * 3.2);
_sparkCol[j] *= fade; _sparkCol[j + 1] *= fade; _sparkCol[j + 2] *= fade;
}
_sparkPts.geometry.attributes.position.needsUpdate = true;
_sparkPts.geometry.attributes.color.needsUpdate = true;
_sparkPts.visible = any;
}
// #4 Bloom — .then() body extracted for test harness reach (Toby r1 F1).
// Called with [EC, RP, UB, OP] when all four postprocessing modules resolve.
// DI rewire: T/ren/scene/cam/highwayCanvas from live-accessors (per-call).
function _applyBloom([EC, RP, UB, OP]) {
// VERBATIM MOVE of the .then() handler body from _bloomEnsure.
// DI rewire: T/ren/scene/cam/highwayCanvas read from live-accessors.
try {
const T = getT(), highwayCanvas = getHighwayCanvas();
const ren = getRen(), scene = getScene(), cam = getCam();
const sz = canvasSize(highwayCanvas) || { w: 1280, h: 720 };
const w = Math.max(2, sz.w | 0), h = Math.max(2, sz.h | 0);
// Multisampled (WebGL2 MSAA) HalfFloat target so anti-aliasing
// survives the bloom path — EffectComposer's default target has no
// `samples`, which is why bloom-on looked jagged (worst on non-Retina
// DPR1 displays that have no supersampling cushion).
const _bloomRT = new T.WebGLRenderTarget(w, h, { type: T.HalfFloatType, samples: 4 });
const comp = new EC.EffectComposer(ren, _bloomRT);
comp.addPass(new RP.RenderPass(scene, cam));
const bp = new UB.UnrealBloomPass(new T.Vector2(w, h), 0.65, 0.5, 0.82); // strength, radius, threshold (high → only emissive blooms)
setBloomPass(bp);
comp.addPass(bp);
comp.addPass(new OP.OutputPass());
comp.setSize(w, h);
setBloomW(w); setBloomH(h); setComposer(comp);
} catch (e) { console.warn('[3D-Hwy] bloom init failed', e); setComposer(null); }
}
// #4 Bloom: lazy-load the vendored postprocessing addons and build an
// EffectComposer (RenderPass -> UnrealBloomPass -> OutputPass/ACES). Returns
// the composer once ready, or null (caller falls back to a direct render).
function _bloomEnsure() {
// VERBATIM MOVE. DI rewire: all factory-scope vars via get/set accessors.
// _composer, _bloomLoad, _bloomPass, _bloomW, _bloomH are REASSIGNED via
// _applyBloom — setters required; must NOT silently become locals.
if (getComposer()) return getComposer();
const ren = getRen(), scene = getScene(), cam = getCam();
if (getBloomLoad() || !ren || !scene || !cam) return null;
const A = '/static/vendor/three/addons/';
setBloomLoad(Promise.all([
import(A + 'postprocessing/EffectComposer.js'),
import(A + 'postprocessing/RenderPass.js'),
import(A + 'postprocessing/UnrealBloomPass.js'),
import(A + 'postprocessing/OutputPass.js'),
]).then(_applyBloom).catch((e) => console.warn('[3D-Hwy] bloom modules failed', e)));
return null;
}
return { _h3dHexOrDefault, _applyCinematic, _timingHex, _sparkBurst, _sparkUpdate, _applyBloom, _bloomEnsure };
}
-281
View File
@@ -1,281 +0,0 @@
/**
* Pure geometry helpers h3d-carve-1.
*
* All exports are stateless; they depend only on the compile-time constants
* below (which mirror their factory-scope counterparts in screen.js verbatim
* and never vary at runtime). No DOM, no Three.js imports, no side-effects.
*
* screen.js keeps a 1-arg delegator:
* const fretX = f => geoFretX(f, _h3dFretUniform);
* so no call site in screen.js changes.
*/
// ── Compile-time constants (mirror screen.js; never vary at runtime) ─────────
const SCALE = 2.25;
const K = SCALE / 300;
// Horizontal stretch factor for fret X positions.
const FRET_SCALE = SCALE * 1.1;
const NFRETS = 24;
/**
* Pure 12-semitone spacing compresses toward the bridge; multiply each
* segment above this fret by the factor so high positions stay
* slightly more playable/readable in 3D.
*/
const FRET_SPACING_STRETCH_ABOVE12 = 1.1;
const FRET_SPACING_ANCHOR_F = 12;
/** Note travel speed. */
const TS = 230 * K;
// ── Fret X ───────────────────────────────────────────────────────────────────
// Logarithmic spacing — mirrors real guitar fret geometry (12th root of 2).
const _fretXLog = f => {
if (f <= 0) return 0;
const raw = FRET_SCALE - FRET_SCALE / Math.pow(2, f / 12);
if (f <= FRET_SPACING_ANCHOR_F) return raw;
const rawAnchor = FRET_SCALE - FRET_SCALE / Math.pow(2, FRET_SPACING_ANCHOR_F / 12);
return rawAnchor + (raw - rawAnchor) * FRET_SPACING_STRETCH_ABOVE12;
};
// Uniform spacing — same column width per fret (chart-format style).
// Total board width equals the logarithmic NFRETS position for consistency.
const _fretXUniStep = _fretXLog(NFRETS) / NFRETS;
const _fretXUni = f => f <= 0 ? 0 : f * _fretXUniStep;
/**
* World-space X position for fret `f`.
* @param {number} f fret number
* @param {boolean} uniform true uniform (chart-format) spacing; false logarithmic
*/
export const geoFretX = (f, uniform) => uniform ? _fretXUni(f) : _fretXLog(f);
// ── Time → Z ─────────────────────────────────────────────────────────────────
/** Convert a time delta (seconds) to a world-space Z offset (notes travel toward Z). */
export const dZ = dt => -dt * TS;
// ── Slide trail ───────────────────────────────────────────────────────────────
/**
* Pitched slide uses `sl`, unpitched uses `slu` (slide-to vs unpitched slide fields).
* Prefer `sl` when both are present matches RS wire.
* @returns {{ endFret: number, unpitched: boolean } | null}
*/
export function slideTrailEnd(n) {
const sl = n.sl;
const slu = n.slu;
if (Number.isFinite(sl) && sl >= 0) {
return { endFret: sl | 0, unpitched: false };
}
if (Number.isFinite(slu) && slu >= 0) {
return { endFret: slu | 0, unpitched: true };
}
return null;
}
// ── Camera distance building blocks ──────────────────────────────────────────
// Camera tgtDist building blocks. Both the dynamic (camera-follow)
// and locked (frets 1-12) branches compose tgtDist from these, so
// any future tuning of the base zoom curve or low-fret pullback
// lands in both branches without drift.
// span — camDistMax - camDistMin in fret-span units
// minFret — lowest fretted note in the camera window (or 1 for
// the locked branch, which assumes nut chords)
export const camBaseDistU = span => 65 + Math.max(span, 4) * 3;
export const camLowFretPullbackU = minFret => Math.max(0, 5 - minFret) * 4;
// ── BPM estimation ────────────────────────────────────────────────────────────
export function computeBPM(beats, t) {
if (!beats || beats.length < 2) return 120;
let lo = 0, hi = beats.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (beats[mid].time < t) lo = mid + 1; else hi = mid;
}
let closest = lo;
if (lo === beats.length) closest = beats.length - 1;
else if (lo > 0 && Math.abs(beats[lo - 1].time - t) < Math.abs(beats[lo].time - t)) closest = lo - 1;
const start = Math.max(0, closest - 2);
const end = Math.min(beats.length - 1, closest + 2);
let sum = 0, count = 0;
for (let i = start; i < end; i++) {
const dt = beats[i + 1].time - beats[i].time;
if (dt > 0) { sum += dt; count++; }
}
return count > 0 && sum > 0 ? 60 / (sum / count) : 120;
}
// ── Render-order layer stack — h3d-carve-1b ───────────────────────────────────
export const RENDER_ORDER_LAYER_STACK = Object.freeze([
'CHORD_FILL',
'CHORD_STRUM_FILL',
'CHORD_STRUM_LINE',
'SUSTAIN_TRAIL',
'CHORD_FRAME',
'CHORD_EDGE_GLOW',
'CONNECTOR_LINE',
'FRET_COLUMN',
'ARP_CONNECTOR_LINE',
'NOTE_OUTLINE',
'NOTE_CORE',
'TECHNIQUE_MARKER',
'BOARD_STRING',
'BOARD_FRET_WIRE',
'NOTE_FRET_LABEL',
'ARP_NOTE_FRET_LABEL',
'CHORD_FRET_LABEL',
]);
export const RENDER_ORDER_LAYER_INDEX = Object.freeze(RENDER_ORDER_LAYER_STACK.reduce(
(indexByLayer, layerName, layerIndex) => {
indexByLayer[layerName] = layerIndex;
return indexByLayer;
},
Object.create(null)
));
export const RENDER_ORDER_AT_Z_ZERO = 700;
export const RENDER_ORDER_FAR_CLAMP = 50;
/**
* Computes renderOrder from world depth plus a named layer.
* Closer objects receive larger values and paint over farther objects; the
* layer stack breaks ties at the same depth, keeping labels above note gems.
*
* The layer index is added as a sub-unit fraction (< 1) so the integer
* depth bucket STRICTLY dominates: a farther object can never outrank a
* nearer one merely because it sits on a higher layer. Adding the raw index
* (0..N-1) directly would let the ~N-wide layer span leak across depth
* buckets and re-introduce far-over-near bleed for notes within ~N draw
* units of each other. Fraction granularity (1/N 0.06) stays well above
* the 0.0001 intra-element sub-increments used at some call sites.
*/
export function renderOrderForLayerAtZ(worldZ, layerName) {
const layerIndex = RENDER_ORDER_LAYER_INDEX[layerName];
if (layerIndex === undefined) throw new Error(`Unknown 3D highway depth layer: ${layerName}`);
const depthRenderOrder = Math.max(
RENDER_ORDER_FAR_CLAMP,
Math.round(RENDER_ORDER_AT_Z_ZERO + worldZ / K)
);
return depthRenderOrder + layerIndex / RENDER_ORDER_LAYER_STACK.length;
}
// ── Note key and binary search — h3d-carve-1b ─────────────────────────────────
// Fast integer key for (t, s) pairs — avoids per-frame string allocation in
// hot-path Set lookups. Encodes chart time in 0.1 ms steps (sufficient for
// chart-format note precision) combined with the string index.
// t range 0600 s → 06,000,000; * 10 + s(07) = max 60,000,007 < 2^53 ✓.
// The |0 truncates to int32 but the outer multiply stays in float64, so the
// key is always a safe JS integer for songs ≤ 214,748 s (well above any song).
export function _noteKey(t, s) { return ((t * 10000 + 0.5) | 0) * 10 + s; }
// Binary lower-bound: returns the first index i in arr where arr[i].t >= t.
// Assumes arr is sorted ascending by .t (bundle.notes / bundle.chords always are).
// Byte-identical to core's bundle.lowerBoundT — kept as a local because this
// plugin must run on downlevel hosts whose bundles don't carry the helper
// (it's called from ~30 sites incl. top-level helpers that don't receive a
// bundle). New code that already holds a bundle should prefer
// bundle.lowerBoundT / bundle.lowerBoundTime.
export function lowerBoundT(arr, t) {
let lo = 0, hi = arr.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (arr[mid].t < t) lo = mid + 1;
else hi = mid;
}
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.
*/
export 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;
}
// ── Fret mid — h3d-carve-1b ───────────────────────────────────────────────────
/**
* World-space X of the midpoint of fret column f.
* f <= 0 nut-side sentinel (2K).
* screen.js keeps `const fretMid = f => geoFretMid(f, _h3dFretUniform);`
* so no call-site changes in screen.js.
* @param {number} f fret number
* @param {boolean} uniform true uniform spacing; false logarithmic
*/
export const geoFretMid = (f, uniform) => f <= 0 ? -2 * K : (geoFretX(f - 1, uniform) + geoFretX(f, uniform)) / 2;
// ── Gaussian bloom texture ────────────────────────────────────────────────────
// Build a horizontal gaussian DataTexture for the sustain-rail bloom effect.
// Returns a W×1 RGBA texture where alpha follows exp(-0.5*(u0.5)²/σ²),
// peaking at 1.0 in the centre. With the default σ=0.28 the edges retain
// ~0.20 alpha (not fully transparent) — a deliberately soft, wide falloff
// so the additive bloom fades gradually rather than cutting off sharply.
// Power-of-two width keeps WebGL mipmapping happy.
export function _makeGaussTex(ThreeLib, w = 128, sigma = 0.28) {
const data = new Uint8Array(w * 4);
for (let i = 0; i < w; i++) {
const u = i / (w - 1);
const d = (u - 0.5) / sigma;
const v = Math.exp(-0.5 * d * d);
const a = Math.round(v * 255);
data[i * 4] = 255;
data[i * 4 + 1] = 255;
data[i * 4 + 2] = 255;
data[i * 4 + 3] = a;
}
const tex = new ThreeLib.DataTexture(data, w, 1, ThreeLib.RGBAFormat);
// LinearFilter on both axes so the bloom plane interpolates smoothly
// when scaled — the default NearestFilter causes visible banding.
tex.magFilter = ThreeLib.LinearFilter;
tex.minFilter = ThreeLib.LinearFilter;
tex.needsUpdate = true;
return tex;
}
-670
View File
@@ -1,670 +0,0 @@
// src/materials.js — h3d-carve-6
// Material builders extracted from screen.js N-section.
//
// screen.js usage (factory scope, once per createFactory() invocation):
// const _techMatCache = new Map();
// const { txtMat, pinchHarmonicMat, naturalHarmonicMat,
// palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
// triMat, bendChevronMat, darkenHex, slideArrowMat,
// _meshMatForGhostFretDigit, _spriteMat2MeshMat, pool } =
// createMaterialBuilders({
// getT, // () => T — live accessor (T is null before loadThree resolves)
// getTxtCache, // () => txtCache — live accessor (txtCache reassigned to {} on teardown)
// techMatCache, // stable const Map ref (screen.js factory-scope; teardown .values()/.clear())
// techMeshMatClones, // stable const Set ref (screen.js factory-scope; teardown .clear())
// });
//
// Surprises vs plan §4:
// • DI is 4 params, not just { getT } — declared before commit
// • _syncOpenStringPitchLabels cluster (20+ factory-scope deps) excluded from this cut
// • _techMatCache stays in screen.js factory scope so teardown can call .values()/.clear() directly
//
// Beyond-subst rewires (4):
// 1. Factory wrapper createMaterialBuilders({...})
// 2. T → const T = getT() at the top of each function that needs Three.js
// 3. txtCache[k] → const cache = getTxtCache(); cache[k]
// 4. _techMatCache → techMatCache / _techMeshMatClones → techMeshMatClones (DI param names)
// 5. _pmXSpriteMat, _fhXSpriteMat promoted to module closure (scope change only)
export function createMaterialBuilders({ getT, getTxtCache, techMatCache, techMeshMatClones }) {
// ── Private closure vars (were factory-scope lets in screen.js N-section) ─
let _pmXSpriteMat = null;
let _fhXSpriteMat = null;
// ── Text-sprite style presets ─────────────────────────────────────────────
// Each preset describes how a class of label is rasterised.
// Tweak per-class look here (font, outline color/width, source
// canvas size). `wide` toggles a long aspect ratio for multi-char
// labels (chord/section names, "↑1/2", "~~~").
//
// Knobs:
// font — full CSS font shorthand (weight + size + family)
// wideFont — same, used when caller passes wide=true
// srcH — source-canvas height in px (square; wide=4×).
// Keep power-of-two so WebGL1 / Three.js retain
// mipmaps + linear-mip-linear filtering — NPOT
// textures silently fall back to no-mipmap and
// shimmer at distance.
// stroke — outline color (null = no outline)
// strokeW — outline line-width in source-canvas px
// shadow — { color, blur, dx, dy } or null
const TXT_STYLES = {
// The two fret-number sets the user wants to pop hardest.
fretRow: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
noteFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Ghost-fret labels on the board projection: same weight/size/outline
// as noteFret, but uses textAlign='center'; textBaseline='middle'
// (the standard branch in txtMat) so the glyph is truly centred on
// the PlaneGeometry UV. inkCenterFret's actualBoundingBox path is
// intentionally NOT activated for this style — that path was designed
// for Sprites and shifts the canvas origin, which causes visible
// lower-left drift on Mesh + MeshBasicMaterial (UV-direct mapping).
ghostFret: {
font: '900 160px "Arial Black", "Helvetica Neue", Arial, sans-serif',
wideFont: '900 128px "Arial Black", "Helvetica Neue", Arial, sans-serif',
srcH: 256, stroke: '#0a1018', strokeW: 18,
shadow: { color: 'rgba(0,0,0,0.7)', blur: 14, dx: 0, dy: 0 },
},
// Chord names — gold script-style label, lighter outline keeps
// the colour readable.
chord: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Section banners ("Verse", "Chorus") — same as chord weight.
section: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Technique markers (pinch-harmonic icon, PM, AC, H/P/T, etc.).
technique: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
// Open-string "0" label on the note body itself.
open: {
font: 'bold 80px sans-serif',
wideFont: 'bold 64px sans-serif',
srcH: 128, stroke: '#0a1018', strokeW: 6, shadow: null,
},
};
function txtMat(text, col, wide, style) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const sName = style || 'technique';
const k = sName + '|' + (wide ? 'W' : '') + text + '|' + col;
if (cache[k]) return cache[k];
const sp = TXT_STYLES[sName] || TXT_STYLES.technique;
const h = sp.srcH;
const str = String(text);
const font = wide ? sp.wideFont : sp.font;
let w = wide ? h * 4 : h;
if (!wide && sName === 'noteFret') {
// Wide labels (D#2, Bb3) need a canvas wider than srcH; cap so
// glyphs stay centred at (w/2, h/2) without edge clipping.
const probe = document.createElement('canvas').getContext('2d');
probe.font = font;
const tw = probe.measureText(str).width;
let pad = 0;
if (sp.stroke && sp.strokeW > 0) pad += sp.strokeW * 2;
if (sp.shadow) {
pad += Math.abs(sp.shadow.dx) + sp.shadow.blur * 2;
}
w = Math.min(12 * h, Math.max(h, Math.ceil(tw + pad)));
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
const x = c.getContext('2d');
x.font = font;
// Fret / open-string digits: anchor from actualBoundingBox so the
// glyph sits at the true optical centre of the canvas (fixes
// sprites looking off-centre inside the board ghost and elsewhere).
const inkCenterFret = !wide && (sName === 'noteFret' || sName === 'open');
// Ghost fret labels live on a PlaneGeometry Mesh (UV-direct), not a Sprite
// billboard. Sprites tolerate slight canvas off-centering because Three.js
// centres them at their world position; a Mesh does not — the digit lands
// wherever it sits in UV space. Use the advance-width centre as the initial
// pen position and then correct for any ink asymmetry via actualBoundingBox.
const inkCenterGhost = !wide && sName === 'ghostFret';
let drawX = w / 2;
let drawY = h / 2;
if (inkCenterFret) {
x.textAlign = 'left';
x.textBaseline = 'alphabetic';
const m = x.measureText(str);
const L = m.actualBoundingBoxLeft;
const R = m.actualBoundingBoxRight;
const A = m.actualBoundingBoxAscent;
const D = m.actualBoundingBoxDescent;
if (
L != null && R != null && A != null && D != null &&
Number.isFinite(L) && Number.isFinite(R) &&
Number.isFinite(A) && Number.isFinite(D)
) {
const inkW = R - L;
drawX = (w - inkW) / 2 - L;
drawY = (h + A - D) / 2;
// Tab digits sit visually a hair low vs bbox (stroke/shadow);
// small canvas nudge keeps sprites centred on the board ghost.
if (sName === 'noteFret') drawY -= h * 0.028;
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
drawX = w / 2;
drawY = h / 2;
}
} else if (inkCenterGhost) {
// Alpha-weighted centroid approach on FILL-ONLY ink (no shadow, no
// stroke) to find the true ink centre of mass without contamination
// from the isotropic shadow blur. For Arial Black "1" the shadow from
// the thin upper-left flag bleeds leftward and cancels part of the
// rightward correction when we include it in the scan. Measuring fill
// alone isolates the actual glyph shape.
// 1. Draw fill-only (no shadow, no stroke) at (w/2, h/2) on temp canvas.
// 2. Compute Σ(px·alpha) / Σ(alpha) → ink centroid.
// 3. Shift drawX/drawY so centroid lands exactly at (w/2, h/2).
// Max 4 unique digits (14) → cache-miss runs at most 4 times ever.
x.textAlign = 'center';
x.textBaseline = 'middle';
try {
const tmpC = document.createElement('canvas');
tmpC.width = w; tmpC.height = h;
const tc = tmpC.getContext('2d');
tc.font = font;
tc.textAlign = 'center';
tc.textBaseline = 'middle';
// Deliberately NO shadow and NO stroke — shadow spreads isotropically
// and muddles the centroid; fill alone gives the cleanest reading.
tc.fillStyle = '#ffffff';
tc.fillText(str, w / 2, h / 2);
const id = tc.getImageData(0, 0, w, h).data;
// Alpha-weighted centroid — heavier ink pixels (thick vertical stem
// of "1") outweigh thin/sparse pixels (diagonal flag), producing the
// correct perceptual centre rather than the geometric bbox midpoint.
let sumX = 0, sumY = 0, sumA = 0;
for (let py = 0; py < h; py++) {
for (let px = 0; px < w; px++) {
const a = id[(py * w + px) * 4 + 3];
if (a > 4) { sumX += px * a; sumY += py * a; sumA += a; }
}
}
if (sumA > 0) {
// shift pen so centroid → canvas centre, then add a small
// extra rightward nudge (8 %) so the vertical stroke of
// narrow digits like "1" sits visually at gem centre rather
// than the advance-width centre (which may be slightly left
// of the dominant ink mass for Arial Black numerals).
drawX = w / 2 + (w / 2 - sumX / sumA) + w * 0.08;
drawY = h / 2 + (h / 2 - sumY / sumA);
}
} catch (_) { /* fallback: draw at (w/2, h/2) */ }
// x (real canvas) still has textAlign='center'; textBaseline='middle'
} else {
x.textAlign = 'center';
x.textBaseline = 'middle';
}
if (sp.shadow) {
x.shadowColor = sp.shadow.color;
x.shadowBlur = sp.shadow.blur;
x.shadowOffsetX = sp.shadow.dx;
x.shadowOffsetY = sp.shadow.dy;
}
if (sp.stroke && sp.strokeW > 0) {
x.lineJoin = 'round';
x.miterLimit = 2;
x.strokeStyle = sp.stroke;
x.lineWidth = sp.strokeW;
x.strokeText(str, drawX, drawY);
}
x.fillStyle = col;
x.fillText(str, drawX, drawY);
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
// depthTest:false means later geometry never *fails* depth
// against these sprites, but without depthWrite:false the
// sprites still write to the depth buffer (Three.js default
// is depthWrite:true even for SpriteMaterial). That can
// make subsequent sprites/labels vanish — match the
// pattern used by the other sprite materials in this file.
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
function pinchHarmonicMat(col) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const baseCol = new T.Color(col != null ? col : '#ffd84d');
// v5 — compact concentric ellipses:
// 1. black outer border rx=0.430h ry=0.255h
// 2. string-color body rx=0.418h ry=0.232h
// 3. black inner ring rx=0.407h ry=0.218h
// 4. string-color inner rx=0.264h ry=0.218h
// 5. black center dot rx=0.134h ry=0.120h
const k = 'technique|pinchHarmonicIcon|rs2014-v5b|' + baseCol.getHexString();
if (cache[k]) return cache[k];
const h = 512;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const TAU = Math.PI * 2;
const colStr = `rgb(${Math.round(baseCol.r * 255)},${Math.round(baseCol.g * 255)},${Math.round(baseCol.b * 255)})`;
x.clearRect(0, 0, h, h);
x.save();
x.translate(h / 2, h / 2);
// Form 1 — black outer border
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.430, h * 0.255, 0, 0, TAU); x.fill();
// Form 2 — string-color main body
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.418, h * 0.232, 0, 0, TAU); x.fill();
// Form 3 — black inner ring
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.407, h * 0.218, 0, 0, TAU); x.fill();
// Form 4 — string-color inner spot (narrower)
x.fillStyle = colStr;
x.beginPath(); x.ellipse(0, 0, h * 0.2637, h * 0.218, 0, 0, TAU); x.fill();
// Form 5 — black center dot
x.fillStyle = '#000000';
x.beginPath(); x.ellipse(0, 0, h * 0.134, h * 0.120, 0, 0, TAU); x.fill();
x.restore();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
function naturalHarmonicMat() {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const k = 'technique|naturalHarmonicIcon|pink-ring-v3';
if (cache[k]) return cache[k];
const h = 256;
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
const cx = h / 2;
const cy = h / 2;
const TAU = Math.PI * 2;
x.clearRect(0, 0, h, h);
const glow = x.createRadialGradient(cx, cy, h * 0.03, cx, cy, h * 0.47);
glow.addColorStop(0, 'rgba(255,170,255,0.14)');
glow.addColorStop(0.55, 'rgba(0,0,0,0.22)');
glow.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = glow;
x.beginPath();
x.arc(cx, cy, h * 0.44, 0, TAU);
x.fill();
x.shadowColor = 'rgba(0,0,0,0.85)';
x.shadowBlur = 14;
x.fillStyle = 'rgba(255, 255, 255, 0.96)';
x.beginPath();
x.arc(cx, cy, h * 0.31, 0, TAU);
x.fill();
// Punch out the inner gap so the icon reads as a bright ring.
x.shadowBlur = 0;
x.globalCompositeOperation = 'destination-out';
x.beginPath();
x.arc(cx, cy, h * 0.20, 0, TAU);
x.fill();
x.globalCompositeOperation = 'source-over';
x.shadowColor = 'rgba(0, 0, 0, 0.7)';
x.shadowBlur = 10;
x.strokeStyle = 'rgba(255, 255, 255, 0.98)';
x.lineWidth = 8;
x.beginPath();
x.arc(cx, cy, h * 0.255, 0, TAU);
x.stroke();
x.shadowColor = 'rgba(0,0,0,0)';
x.fillStyle = 'rgba(255, 255, 255, 0.98)';
x.beginPath();
x.arc(cx, cy, h * 0.12, 0, TAU);
x.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
opacity: 0.96,
});
cache[k] = mat;
return mat;
}
// Only two PM/FH variants exist (palm-mute = black-on-white,
// fret-hand mute = white-on-black). drawNote() hits muteXMat per
// muted chord-note per frame, so dense PM/FH passages were paying
// for a string concat + Map lookup on every call. Hoist both
// SpriteMaterial refs and short-circuit before touching the cache.
// They're populated lazily on first use; teardown still reaches
// them via the shared ``txtCache`` because muteXMat writes there.
function palmMuteXSpriteMat() {
return _pmXSpriteMat ?? (_pmXSpriteMat = muteXMat('#000000', '#ffffff'));
}
function fretHandMuteXSpriteMat() {
return _fhXSpriteMat ?? (_fhXSpriteMat = muteXMat('#ffffff', '#000000'));
}
function muteXMat(fillCol, strokeCol) {
const T = getT(); // beyond-subst: T via live accessor
const cache = getTxtCache(); // beyond-subst: txtCache → getTxtCache()
const k = 'technique|muteX|v2|' + String(fillCol) + '|' + String(strokeCol);
if (cache[k]) return cache[k];
// lineCap:'square' gives flat tips. For a 45° diagonal the square-cap
// corners sit at ±outerW/2 rotated 45° from the endpoint — they land
// outside the canvas unless pad ≥ outerW/√2 (the common mistake is
// using outerW/2, which is too small). With the correct pad the white
// cap is fully inside the canvas and the border is visible at every tip.
const h = 512;
const outerW = 132, innerW = 114;
// pad must satisfy: pad ≥ outerW / Math.SQRT2 (≈ outerW × 0.707)
const pad = Math.ceil(outerW / Math.SQRT2) + 2; // 96
const c = document.createElement('canvas');
c.width = h; c.height = h;
const x = c.getContext('2d');
x.clearRect(0, 0, h, h);
x.lineCap = 'square';
// Draw each diagonal in its own stroke() call — caps of the two
// diagonals don't interact, and the white outer is drawn before the
// black inner so the border is clean at every edge and tip.
x.strokeStyle = strokeCol;
x.lineWidth = outerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
x.strokeStyle = fillCol;
x.lineWidth = innerW;
x.beginPath(); x.moveTo(pad, pad); x.lineTo(h - pad, h - pad); x.stroke();
x.beginPath(); x.moveTo(h - pad, pad); x.lineTo(pad, h - pad); x.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c),
transparent: true,
depthTest: false,
depthWrite: false,
});
cache[k] = mat;
return mat;
}
// Technique-marker sprite materials (triangle / chevron). Keyed by a
// packed NUMBER, not a string — triMat/bendChevronMat are called from
// the drawNote hot path, so a string cache key would allocate per
// note per frame. Disposed in teardown. `hex` is a 0xRRGGBB number;
// the low nibble of the key tags the variant (0 ▲, 1 ▼, 3-6 chevron
// step-count) so triangle and chevron entries can't collide.
// Hammer-on / pull-off triangle marker: a white ▲ (up) / ▼ (down)
// with a thick border in the gem's string colour.
function triMat(up, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + (up ? 0 : 1);
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256, m = S * 0.15;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (up) { g.moveTo(S / 2, m); g.lineTo(S - m, S - m); g.lineTo(m, S - m); }
else { g.moveTo(S / 2, S - m); g.lineTo(S - m, m); g.lineTo(m, m); }
g.closePath();
g.lineJoin = 'round';
g.fillStyle = '#ffffff';
g.fill();
g.lineWidth = S * 0.122;
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.stroke();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
// Strength-of-bend chevron stack: `steps` (1-4) chevrons in the gem's
// string colour (chart-format bend notation — 1 per half-step).
function bendChevronMat(steps, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 2 + steps; // steps 1-4 → low nibble 3-6
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.strokeStyle = '#' + (hex >>> 0).toString(16).padStart(6, '0');
g.lineWidth = S * 0.10;
g.lineJoin = g.lineCap = 'round';
const padX = S * 0.18;
const rowH = S / steps;
const amp = Math.min(rowH * 0.55, S * 0.24);
for (let i = 0; i < steps; i++) {
const cy = (i + 0.5) * rowH;
g.beginPath();
g.moveTo(padX, cy + amp * 0.5);
g.lineTo(S / 2, cy - amp * 0.5);
g.lineTo(S - padX, cy + amp * 0.5);
g.stroke();
}
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
// Darken a 0xRRGGBB colour by `factor` (0..1) for the slide-arrow
// marker — full string colour is too bright next to the gem.
function darkenHex(hex, factor) {
const h = (hex >>> 0) & 0xffffff;
const r = Math.round(((h >> 16) & 0xff) * factor);
const g = Math.round(((h >> 8) & 0xff) * factor);
const b = Math.round((h & 0xff) * factor);
return (r << 16) | (g << 8) | b;
}
// Slide-direction arrow (/): a filled triangle pointing toward the
// slide's destination fret, in the gem's (darkened) string colour.
// `hex` here is already the darkened colour — keep its own cache-key
// nibble range (8/9) so it can't collide with triMat (0/1) or
// bendChevronMat (3-6).
function slideArrowMat(pointRight, hex) {
const T = getT(); // beyond-subst: T via live accessor
const h = (hex >>> 0) & 0xffffff;
const key = h * 16 + 8 + (pointRight ? 0 : 1);
const cached = techMatCache.get(key); // beyond-subst: _techMatCache → techMatCache
if (cached) return cached;
const S = 256, m = S * 0.18;
const c = document.createElement('canvas');
c.width = c.height = S;
const g = c.getContext('2d');
g.beginPath();
if (pointRight) { g.moveTo(S - m, S / 2); g.lineTo(m, m); g.lineTo(m, S - m); }
else { g.moveTo(m, S / 2); g.lineTo(S - m, m); g.lineTo(S - m, S - m); }
g.closePath();
g.fillStyle = '#' + h.toString(16).padStart(6, '0');
g.fill();
const mat = new T.SpriteMaterial({
map: new T.CanvasTexture(c), transparent: true,
depthTest: false, depthWrite: false,
});
techMatCache.set(key, mat); // beyond-subst: _techMatCache → techMatCache
return mat;
}
function _meshMatForGhostFretDigit(spriteMat) {
const T = getT(); // beyond-subst: T via live accessor
let mb = spriteMat.userData.h3dGhostFretMeshMat;
if (!mb) {
mb = new T.MeshBasicMaterial({
map: spriteMat.map,
transparent: true,
depthTest: false,
depthWrite: false,
});
spriteMat.userData.h3dGhostFretMeshMat = mb;
}
return mb;
}
/**
* Convert any SpriteMaterial to a MeshBasicMaterial that shares its canvas
* texture, so technique markers can be applied to a rotatable PlaneGeometry
* mesh instead of a billboard Sprite. Cached on userData to avoid allocations.
*
* The cache is multi-entry: each pTechPlane mesh holds a Map<sm.map,
* clone> so a recycled mesh that's used for several techniques
* (hammer-on, palm-mute, harmonic, bend...) across frames keeps a
* clone for each one rather than disposing-and-recloning on every
* switch. With nStr-wide chords containing mixed PM/FH/HO/HP
* markers this collapses the per-frame allocation entirely while
* still being bounded the per-mesh Map has at most one entry per
* distinct technique × colour the mesh has ever been used for.
*/
function _spriteMat2MeshMat(mesh, sm) {
const T = getT(); // beyond-subst: T via live accessor
let perMesh = mesh.userData.h3dTechMeshMatCloneByMap;
if (perMesh) {
const hit = perMesh.get(sm.map);
if (hit) return hit;
}
let base = sm.userData.h3dTechMeshMat;
if (!base) {
base = new T.MeshBasicMaterial({
map: sm.map,
transparent: true,
// depthTest: false — cross-note Z ordering is handled by
// per-note renderOrderForLayerAtZ(...) calls rather than the
// depth buffer. This is necessary because close notes often use
// mGlow (depthWrite:false), so the depth buffer can't reliably
// occlude far markers near the hit line. With per-note renderOrder,
// far labels render first and close note geometry renders last,
// appearing on top without depthTest.
depthTest: false,
depthWrite: false,
// forceSinglePass accompanies EVERY transparent DoubleSide
// material in this file: without it, Three r158+ renders
// each such object in TWO passes (back side then front),
// setting material.needsUpdate on both — which forces a
// full getParameters/program-cache lookup per object per
// frame (profiled at ~4% of throttled main-thread time)
// and doubles the draw calls. The two-pass path exists to
// fix self-occlusion sorting on closed transparent meshes;
// all our DoubleSide materials are flat unlit quads
// (labels, rails, frames, lanes) where it buys nothing.
side: T.DoubleSide, forceSinglePass: true,
});
sm.userData.h3dTechMeshMat = base;
}
// First conversion for this mesh: the pTechPlane pool factory gave
// it a placeholder MeshBasicMaterial that the caller is about to
// overwrite with the clone below. Dispose it now — once
// mesh.material is reassigned the placeholder is orphaned and
// teardown's scene.traverse() pass can no longer reach it, so it
// would leak one GPU material per pooled mesh for the renderer's
// lifetime.
if (!perMesh && mesh.material && mesh.material !== base) {
mesh.material.dispose?.();
}
if (!perMesh) {
perMesh = new Map();
mesh.userData.h3dTechMeshMatCloneByMap = perMesh;
}
const clone = base.clone();
perMesh.set(sm.map, clone);
techMeshMatClones.add(clone); // beyond-subst: _techMeshMatClones → techMeshMatClones
return clone;
}
function pool(parent, mk) {
const a = [];
let n = 0;
return {
get() {
if (n < a.length) {
const o = a[n++];
o.visible = true;
if (o.center && o.center.isVector2) o.center.set(0.5, 0.5);
return o;
}
const o = mk(); parent.add(o); a.push(o); n++; return o;
},
reset() { for (let i = 0; i < n; i++) a[i].visible = false; n = 0; },
// Pre-allocate `cap` slots at construction so the first dense
// playback frames don't pay the new-Mesh allocation cost
// mid-RAF (felt as a stall on 7/8-string charts where the
// visible-note count outruns the lazy-grow path). Lazy growth
// past `cap` still works — this is amortisation, not a cap.
//
// Coerce `cap` to a non-negative int32: a float would still
// work but a callsite passing `Infinity` (or `NaN`) would
// otherwise spin the while-loop until OOM. `cap | 0`
// truncates floats, clamps Infinity → 0, and turns NaN → 0;
// Math.max(0, …) keeps negatives out.
warm(cap) {
// Local rename to avoid shadowing the pool's outer
// `n` (the in-use index advanced by get() / reset()).
const targetLen = Math.max(0, cap | 0);
while (a.length < targetLen) { const o = mk(); o.visible = false; parent.add(o); a.push(o); }
return this;
},
};
}
return {
txtMat, pinchHarmonicMat, naturalHarmonicMat,
palmMuteXSpriteMat, fretHandMuteXSpriteMat, muteXMat,
triMat, bendChevronMat, darkenHex, slideArrowMat,
_meshMatForGhostFretDigit, _spriteMat2MeshMat, pool,
};
}
File diff suppressed because it is too large Load Diff
-924
View File
@@ -1,924 +0,0 @@
// src/overlay.js — h3d-carve-7
// Lyrics + HUD overlay extracted from screen.js O-section (lines 43385209
// post-cut-6). Pure 2D canvas drawing — no Three.js dependency.
//
// screen.js usage (factory scope, once per createFactory() invocation):
// const { drawChordDiagram, _drawDiagramCached, drawSectionHud,
// drawToneHud, drawLyrics } = createOverlay({
// diagRenderCache, // stable const Map ref (screen.js factory-scope)
// // teardown calls .clear() on it directly; the shared
// // ref makes that visible to the overlay without a callback
// });
//
// Surprises vs contract:
// • longestConsecutiveRun (lines 43384352) co-moved — only called by drawChordDiagram
// • DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX moved here and DELETED from
// screen.js lines 573575 (only used inside O-section)
// • _DIAG_CACHE_MAX moved inside createOverlay body and deleted from
// screen.js factory scope (line 3256) — same factory-scope level, different file
//
// Beyond-subst rewires (2):
// 1. Factory wrapper createOverlay({ diagRenderCache })
// 2. _diagRenderCache → diagRenderCache (DI param) in 3 sites in _drawDiagramCached
// ── Constants moved from screen.js module scope (lines 573575) ─────────────
// Only users were inside the O-section; no other callers remain in screen.js.
const DIAG_SIZE_MIN = 0.08;
const DIAG_SIZE_MAX = 0.16;
const DIAG_CELL_MAX = 34;
export function createOverlay({ diagRenderCache }) {
// ── Moved from screen.js factory scope (line 3256) ───────────────────────
// Cap chosen to cover the ~56 active chord shapes per phrase while
// keeping the cached-OffscreenCanvas footprint bounded (~50 MB per
// panel at typical 1920×1080). A structural fix — caching a
// tightly-sized box surface instead of the full overlay canvas —
// is tracked as a follow-up.
const _DIAG_CACHE_MAX = 6;
// ── Lyrics layout cache — moved from factory scope (line 5077) ───────────
// measureText per syllable + row wrapping only changes when the displayed
// line(s), font size, or canvas width change, not per frame.
let _lyrRowsCache = null;
// Returns indices of the longest consecutive run in a sorted integer
// array as { start, len } — `sorted[start..start+len)` is the run.
// Avoids the two per-call sub-array allocations of the previous
// implementation (best + cur arrays grown via .push), at the cost
// of one small 2-key result object. Net: callers in the chord-
// diagram render path no longer churn arrays per visible chord.
function longestConsecutiveRun(sorted) {
let bestStart = -1, bestLen = 0;
let curStart = -1, curLen = 0;
for (let i = 0; i < sorted.length; i++) {
if (curLen === 0 || sorted[i] === sorted[curStart + curLen - 1] + 1) {
if (curLen === 0) curStart = i;
curLen++;
} else {
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
curStart = i; curLen = 1;
}
}
if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; }
return { start: bestStart, len: bestLen };
}
/* ── Lyrics overlay (2D canvas on top of WebGL) ─────────────────── */
function drawChordDiagram(ctx, opts) {
const {
name, frets,
opacity = 1,
entranceT = 1.0,
canvasW = 600, canvasH = 400,
inverted = false,
sizeSlider = 0.5,
position = 'tl',
nStr = 6,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Responsive sizing — CELL derived from panel height + user slider.
// COLS is the resolved string count from the caller (via resolveStringCount)
// so bass (4), extended (7/8) arrangements render correctly.
const COLS = nStr, ROWS = 4;
// Minimum column span required for PATH B (bracket extension / detection).
// Math.min(COLS-1, 4) scales with string count:
// 4-string bass → 3 (max possible span, so 2-4-4-2 shapes qualify)
// 6-string → 4 (excludes D major span=2 / common 2-string coincidences)
// 8-string → 4 (muted outer strings still leave span ≥ 4 for real barres)
const MIN_BARRE_SPAN = Math.min(COLS - 1, 4);
// Maps diagram column index → chord-template frets-array index.
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Non-inverted display (col 0 = high e): getStrIdx(0) = 0 → frets[0] = high e.
// Inverted display (col 0 = low E): getStrIdx(0) = COLS-1 → frets[COLS-1] = low E.
const getStrIdx = col => inverted ? (COLS - 1 - col) : col;
const sizeF = DIAG_SIZE_MIN + (DIAG_SIZE_MAX - DIAG_SIZE_MIN) * sizeSlider;
// startFret / isFirstPos must be known before CELL so that fretLabelW
// can be measured and factored into the width cap. The old
// canvasW/(COLS+1.5) guard only approximated 2*PAD and ignored the
// extra left padding reserved for non-first-position "Nfr" labels.
const playedFrets = frets.filter(f => f > 0);
const minFret = playedFrets.length > 0 ? Math.min(...playedFrets) : 1;
const startFret = Math.max(1, minFret);
const isFirstPos = startFret === 1;
// Phase 1 — height + hard-cap estimate, used only to size the label font.
// Cap against the vertical space available below lyricsBottom so that the
// diagram does not overflow into the lyrics banner on short split panels with
// wrapped lyric rows. Only top-corner positions can overlap the lyrics banner,
// so lyricsBottom is only subtracted when position is 'tl' or 'tr'; for 'bl'
// and 'br' the full canvas height is available.
// Clamp to at least 1 so font/box calculations never receive 0-px input
// on very short panels (e.g. tiny split cells < 44 px tall).
const isTopCorner = position === 'tl' || position === 'tr';
const availH = canvasH - (isTopCorner ? lyricsBottom : 0);
const cellEst = Math.max(1, Math.min(
Math.round(availH * sizeF / (ROWS + 3)),
DIAG_CELL_MAX,
));
// Extra left padding for the "Nfr" label on non-first-position chords.
// Measured with ctx.measureText at cellEst so the estimate is exact.
let fretLabelW = 0;
if (!isFirstPos) {
// Measure inside a save/restore so this font assignment does not
// leak to the caller (the outer ctx.save() happens after CELL is derived).
ctx.save();
ctx.font = `italic ${Math.round(cellEst * 0.55)}px sans-serif`;
fretLabelW = Math.ceil(ctx.measureText(startFret + 'fr').width) + 6;
ctx.restore();
}
// Phase 2 — final CELL: cap against panel height, hard max, and panel width.
// Two width constraints are needed because PAD has a hard floor of 6:
// A) when PAD = CELL*0.65 (large CELL): CELL*(COLS+0.3) + fretLabelW ≤ canvasW
// B) when PAD = 6 floor (small CELL): CELL*(COLS-1) + 12 + fretLabelW ≤ canvasW
// Both are included so boxW ≤ canvasW in every regime.
// fretLabelW was measured at cellEst ≥ CELL, so the cap is conservative.
const CELL = Math.max(1, Math.min(
cellEst,
Math.floor((canvasW - fretLabelW) / (COLS + 0.3)),
Math.floor((canvasW - 2 * 6 - fretLabelW) / Math.max(1, COLS - 1)),
));
const HEADER = Math.round(CELL * 1.6);
const MARKER = Math.round(CELL * 0.7);
const DOT_R = CELL * 0.3;
const PAD = Math.max(6, Math.round(CELL * 0.65));
const gridW = CELL * (COLS - 1);
const gridH = CELL * ROWS;
const PAD_L = PAD + fretLabelW;
const boxW = gridW + PAD_L + PAD;
const boxH = HEADER + MARKER + gridH + PAD;
// Anchor to chosen corner. Top positions get extra vertical offset
// to clear the timeline plugin and song name displayed at the top.
// lyricsBottom is the actual bottom Y of the lyrics banner (returned by
// drawLyrics), so TOP_Y steps down past all lyric rows regardless of
// how many wrap lines the current panel width produces.
const E = PAD;
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
// Clamp so the box never bleeds off-canvas on narrow panels or wide string counts.
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Guard: the canvasHboxH clamp above can push `by` above lyricsBottom when
// wrapped lyrics consume nearly the full panel height. This applies to ALL
// corner positions: a bottom-corner diagram anchored near the canvas bottom can
// still reach up into the lyrics banner on very short or narrow panels where
// boxH is larger than the space below the lyrics. In those cases skip drawing
// entirely rather than painting on top of the lyrics banner.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
const gx = bx + PAD_L, gy = by + HEADER + MARKER;
// Ease-out quadratic entrance scale: 0.85 → 1.0.
const scale = 1 - 0.15 * (1 - entranceT) * (1 - entranceT);
ctx.save();
ctx.globalAlpha = opacity;
if (scale !== 1.0) {
const cx = bx + boxW / 2, cy = by + boxH / 2;
ctx.translate(cx, cy);
ctx.scale(scale, scale);
ctx.translate(-cx, -cy);
}
// Background + border.
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
// Split-root typography: "Dm7" → "D" large bold + "m7" smaller.
const rootMatch = name.match(/^([A-G][#b]?)(.*)/);
const root = rootMatch ? rootMatch[1] : name;
const quality = rootMatch ? rootMatch[2] : '';
const rootSize = Math.round(CELL * 1.25);
const qualSize = Math.round(rootSize * 0.65);
ctx.textBaseline = 'middle';
const nameY = by + HEADER * 0.55;
ctx.font = `bold ${rootSize}px sans-serif`;
const rootW = ctx.measureText(root).width;
ctx.font = `${qualSize}px sans-serif`;
const qualW = quality ? ctx.measureText(quality).width : 0;
const nameBlockW = rootW + (quality ? qualW + 2 : 0);
const nameStartX = bx + boxW / 2 - nameBlockW / 2;
ctx.fillStyle = '#e8d080';
ctx.font = `bold ${rootSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.fillText(root, nameStartX, nameY);
if (quality) {
ctx.font = `${qualSize}px sans-serif`;
ctx.fillStyle = 'rgba(232,208,128,0.75)';
ctx.fillText(quality, nameStartX + rootW + 2, nameY);
}
// Nut: CELL-proportional filled rect + subtle highlight line.
// Thickness is 40% of CELL, floored at 2 px so it stays visible on
// the smallest diagrams (CELL=1 on compact split panels).
const NUT_H = Math.round(Math.max(2, CELL * 0.4));
if (isFirstPos) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(gx, gy - NUT_H, gridW, NUT_H);
ctx.fillStyle = 'rgba(255,255,255,0.4)';
ctx.fillRect(gx, gy - NUT_H, gridW, Math.max(1, Math.round(NUT_H * 0.25)));
}
// Fret label for non-first-position chords.
if (!isFirstPos) {
ctx.fillStyle = 'rgba(220,200,120,0.9)';
ctx.font = `italic ${Math.round(CELL * 0.55)}px sans-serif`;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(startFret + 'fr', gx - 4, gy + CELL * 0.5);
}
// Fret lines.
ctx.strokeStyle = 'rgba(255,255,255,0.22)'; ctx.lineWidth = 1;
for (let r = (isFirstPos ? 1 : 0); r <= ROWS; r++) {
ctx.beginPath();
ctx.moveTo(gx, gy + r * CELL);
ctx.lineTo(gx + gridW, gy + r * CELL);
ctx.stroke();
}
// String lines with varying weight: low E heavier, high e lighter.
// With getStrIdx(col) = col (non-inverted): col 0 (high e) → strIdx=0 → t=0 thin;
// col COLS-1 (low E) → strIdx=COLS-1 → t=1 thick. Inverted mode naturally mirrors.
// Weights scale with CELL so strings never bleed into adjacent columns on
// small-CELL diagrams (e.g. CELL=1 on compact split panels).
for (let col = 0; col < COLS; col++) {
const strIdx = getStrIdx(col);
const t = COLS > 1 ? strIdx / (COLS - 1) : 1; // 1=low E (thick), 0=high e (thin); guard COLS=1
ctx.lineWidth = Math.max(0.5, CELL * (0.05 + t * 0.10));
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.beginPath();
ctx.moveTo(gx + col * CELL, gy);
ctx.lineTo(gx + col * CELL, gy + ROWS * CELL);
ctx.stroke();
}
// Barre detection — two complementary paths:
//
// PATH A (F-shape / mini-barre): at least two ADJACENT columns are at startFret.
// Bracket is initially set to the consecutive run's own endpoints (not the full
// startFretCols range) so isolated bass notes at the same fret can't pull the
// bracket across an open gap (e.g. "2 0 2 2 0 0" stays bracketed at cols 2..3).
//
// PATH B (full-span barre / extension):
// When PATH A fired: extend the bracket outward to the full outer startFret span
// if the span ≥ MIN_BARRE_SPAN and every column between the outer startFret
// columns is fretted (f > 0).
// When PATH A did NOT fire: detect standalone full barres (e.g. x24442, x46654)
// where only the two outermost strings sit at startFret. An additional check
// ensures that no intermediate column is itself at startFret — this rules out
// alternating-fret voicings like "1 3 1 3 1 0" (col 2 at startFret would fire
// incorrectly) while still catching B-major-style shapes where the barre
// finger covers only the outer two strings.
//
// Templates are high-e-first: frets[0]=high e, frets[COLS-1]=low E.
// Examples (6-string, MIN_BARRE_SPAN=4):
// F major [1,1,2,3,3,1]: PATH A run=[4,5] → bracket 4..5; PATH B span=5, all fretted → extends to 0..5 ✓
// B major x24442: PATH A no run; PATH B span=4, all fretted, no inner at startFret → 1..5 ✓
// mini-A x02220: PATH A run=[2,3,4] → bracket 2..4; PATH B span=2<4 → no extension ✓
// D major xx0232: PATH A run length=1 → no PATH A; PATH B span<4 → no bracket ✓
// 2 0 2 2 0 0: PATH A run=[2,3] → bracket 2..3; PATH B span=3<4 → no extension ✓
// 1 3 1 3 1 0: PATH A no run; PATH B: inner col 2 at startFret → no bracket ✓
const startFretCols = [];
for (let col = 0; col < COLS; col++) {
if (frets[getStrIdx(col)] === startFret) startFretCols.push(col);
}
const barreRun = longestConsecutiveRun(startFretCols);
let hasBarreArc = barreRun.len >= 2; // PATH A
let barreMinCol = hasBarreArc ? startFretCols[barreRun.start] : -1;
let barreMaxCol = hasBarreArc ? startFretCols[barreRun.start + barreRun.len - 1] : -1;
if (startFretCols.length >= 2) { // PATH B
const minC = startFretCols[0];
const maxC = startFretCols[startFretCols.length - 1];
if (maxC - minC >= MIN_BARRE_SPAN) {
let allFretted = true;
for (let col = minC; col <= maxC; col++) {
if (frets[getStrIdx(col)] <= 0) { allFretted = false; break; }
}
if (allFretted) {
if (hasBarreArc) {
// PATH A fired: always safe to extend to full outer span.
barreMinCol = minC;
barreMaxCol = maxC;
} else {
// PATH A did not fire: only draw a bracket when no intermediate
// column sits at startFret. Intermediate startFret columns would
// indicate a scattered/alternating voicing rather than a clean
// outer-edge barre (e.g. "1 3 1 3 1 0" has col 2 at startFret).
let noInnerAtStartFret = true;
for (let col = minC + 1; col < maxC; col++) {
if (frets[getStrIdx(col)] === startFret) { noInnerAtStartFret = false; break; }
}
if (noInnerAtStartFret) {
hasBarreArc = true;
barreMinCol = minC;
barreMaxCol = maxC;
}
}
}
}
}
if (hasBarreArc) {
const barreY = gy + CELL * 0.5;
const capH = CELL * 0.22; // vertical offset from barreY to the bracket line
const capHalf = Math.max(1, Math.round(CELL * 0.3)); // half-height of the vertical end caps
// Straight bracket: a horizontal line with short vertical end caps.
// Stroke scales with CELL so it doesn't swamp tiny cells (floor at 1 px).
ctx.strokeStyle = 'rgba(255,255,255,0.6)'; ctx.lineWidth = Math.max(1, CELL * 0.2);
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMinCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMinCol * CELL, barreY - capH + capHalf);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(gx + barreMaxCol * CELL, barreY - capH - capHalf);
ctx.lineTo(gx + barreMaxCol * CELL, barreY - capH + capHalf);
ctx.stroke();
}
// Open/muted markers + finger dots.
// Non-inverted: col 0 = high e → getStrIdx(0)=0 → frets[0]; col COLS-1 = low E → frets[COLS-1].
// Inverted: col 0 = low E → getStrIdx(0)=COLS-1 → frets[COLS-1]; col COLS-1 = high e → frets[0].
for (let col = 0; col < COLS; col++) {
const f = frets[getStrIdx(col)];
const sx = gx + col * CELL;
const markerY = gy - MARKER * 0.5;
if (f < 0) {
const r = CELL * 0.20;
ctx.strokeStyle = '#cc4444'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(sx - r, markerY - r); ctx.lineTo(sx + r, markerY + r); ctx.stroke();
ctx.beginPath(); ctx.moveTo(sx + r, markerY - r); ctx.lineTo(sx - r, markerY + r); ctx.stroke();
} else if (f === 0) {
ctx.strokeStyle = '#88bbff'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(sx, markerY, CELL * 0.22, 0, Math.PI * 2); ctx.stroke();
} else {
const row = f - startFret;
if (row >= 0 && row < ROWS) {
const isBarreCol = hasBarreArc && f === startFret &&
col >= barreMinCol && col <= barreMaxCol;
ctx.shadowColor = 'rgba(0,0,0,0.5)';
ctx.shadowBlur = Math.min(4, CELL * 0.4);
ctx.shadowOffsetX = Math.max(0.5, CELL * 0.1);
ctx.shadowOffsetY = Math.max(0.5, CELL * 0.1);
ctx.fillStyle = isBarreCol ? 'rgba(255,255,255,0.85)' : '#ffffff';
ctx.beginPath();
ctx.arc(sx, gy + row * CELL + CELL * 0.5, DOT_R, 0, Math.PI * 2);
ctx.fill();
ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 0;
}
}
}
ctx.restore();
return boxH;
}
// Cached wrapper for drawChordDiagram. When entranceT === 1 (scale
// transform is identity) the diagram is rendered once to an
// OffscreenCanvas and reused every subsequent frame via drawImage +
// globalAlpha. During the 0.2 s entrance animation (entranceT < 1)
// the scale transform is non-trivial so we fall through to a fresh
// render — that window is ~12 frames at 60 fps, negligible.
//
// Returns boxH (diagram card height in px) so the draw loop can
// accumulate per-corner stack offsets when multiple overlays share
// the same corner position.
function _drawDiagramCached(ctx, opts) {
const { opacity = 1, entranceT = 1.0, canvasW, canvasH } = opts;
if (opacity <= 0) return 0;
if (entranceT < 1.0) {
return drawChordDiagram(ctx, opts) || 0;
}
const { name, frets, nStr, inverted, sizeSlider, position, lyricsBottom = 0, stackOffset = 0 } = opts;
const key = name + '|' + (frets || []).join(',') + '|' + nStr + '|' +
(inverted ? 1 : 0) + '|' + sizeSlider + '|' + position + '|' +
canvasW + '|' + canvasH + '|' + lyricsBottom + '|' + stackOffset;
let entry = diagRenderCache.get(key);
if (!entry) {
let oc;
try { oc = new OffscreenCanvas(canvasW, canvasH); }
catch (_) { oc = document.createElement('canvas'); oc.width = canvasW; oc.height = canvasH; }
const boxH = drawChordDiagram(oc.getContext('2d'), { ...opts, opacity: 1, entranceT: 1 }) || 0;
if (diagRenderCache.size >= _DIAG_CACHE_MAX) {
diagRenderCache.delete(diagRenderCache.keys().next().value);
}
entry = { oc, boxH };
diagRenderCache.set(key, entry);
}
ctx.save();
ctx.globalAlpha = opacity;
ctx.drawImage(entry.oc, 0, 0);
ctx.restore();
return entry.boxH;
}
// Two-line section card. Top line is "Now: <current>", bottom line
// is "Up Next: <next> in <countdown>". Explicit labels disambiguate
// current vs upcoming — earlier single-line variant rendered both
// states with the same word and was confusing during playback.
//
// Returns boxH on draw, 0 when nothing rendered. Position / size
// mirror the chord-diagram contract: 'tl' / 'tr' / 'bl' / 'br'
// anchor corners, sizeSlider in [0,1] scales card height.
//
// Hidden when:
// - no sections array, or
// - playback has not yet reached the first section AND there's
// no upcoming-only fallback rendered (we still show "Up Next"
// during the pre-roll so the user sees what's coming).
function drawSectionHud(ctx, opts) {
const {
sections, currentTime,
canvasW, canvasH,
position = 'tr',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
if (!sections || !sections.length) return 0;
// sections are time-ordered server-side; single forward scan.
let curIdx = -1;
for (let i = 0; i < sections.length; i++) {
if (sections[i].time <= currentTime) curIdx = i;
else break;
}
const cur = curIdx >= 0 ? sections[curIdx] : null;
const next = (curIdx + 1 < sections.length) ? sections[curIdx + 1] : null;
// Pre-first-section: nothing playing yet but next is coming —
// still useful to render "Up Next" alone so the user gets the
// anticipatory cue during the song's intro silence.
if (!cur && !next) return 0;
const nowName = cur ? cur.name : '';
// Render countdown as a separate span so it can take a calmer
// grey-white treatment while the section name itself stays
// cyan. Combining them into one string would inherit the cyan
// fill across both, defeating the visual hierarchy promised
// in the FR.
let nextName = '';
let nextCountdown = '';
if (next) {
const dt = next.time - currentTime;
nextName = next.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider; // 0.65 .. 1.5
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
// Per-text-element scale applied to nameSize / tagSize / lineH
// when the unscaled card would overflow a narrow panel
// (splitscreen quad layout, ultra-tall portrait). Computed
// below from the measured contentW vs the available width.
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_NOW = 'Now:';
const TAG_NEXT = 'Up Next:';
// Phase-1 measurement at the unscaled font sizes — used to
// decide whether textScale needs to drop, and to lay out the
// final draw at whatever scale we land on.
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagNowWBase = ctx.measureText(TAG_NOW).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const nowNameWBase = nowName ? ctx.measureText(nowName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowWBase = nowName ? tagNowWBase + baseTagGap + nowNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineNowWBase, lineNextWBase);
const numLines = (nowName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
// Target width budget: cap at canvasW - 16 and reserve PAD_X
// either side. If contentWBase exceeds the budget, scale the
// font proportionally — clamped to 0.55 so labels stay legible
// even on extreme split-panel widths.
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
// Phase-2 re-measurement at the scaled font sizes for the
// final layout. measureText doesn't scale linearly with font
// size on every glyph, so re-measuring is cheaper than
// multiplying the base widths by textScale and risking a
// half-pixel overflow.
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagNowW = ctx.measureText(TAG_NOW).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const nowNameW = nowName ? ctx.measureText(nowName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineNowW = nowName ? tagNowW + TAG_GAP + nowNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineNowW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; }
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
// Suppress overlap with the wrapped lyrics banner regardless
// of corner. Bottom-corner cards on short panels can still
// reach up into the banner once boxH exceeds the space below
// the lyrics — same shape the chord diagram uses.
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
// Layout each line with tag left-aligned, name in cyan after a
// small gap. Both lines share the same x origin (bx + PAD_X)
// so the tag column visually aligns vertically.
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#00cccc';
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (nowName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NOW, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nowName, lineX + tagNowW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
// Tone-change HUD — card showing the active tone and the next upcoming
// tone with a countdown. Mirrors drawSectionHud's layout contract
// (position, size slider, lyricsBottom) but uses an amber accent colour
// so it reads as distinct from the cyan section card.
function drawToneHud(ctx, opts) {
const {
toneChanges, toneBase = '',
currentTime,
canvasW, canvasH,
position = 'tl',
sizeSlider = 0.5,
lyricsBottom = 0,
stackOffset = 0,
} = opts;
// Resolve active tone: toneBase before all changes, else the most
// recent change at or before currentTime.
// toneChanges items use { t, name } (not { time, name }) — both
// the legacy import path (server.py xml_tone_changes) and the sloppak
// path (lib/tones.py sloppak_tone_changes) emit "t" as the key.
let curName = toneBase;
let nextChange = null;
if (toneChanges && toneChanges.length) {
for (let i = 0; i < toneChanges.length; i++) {
if (toneChanges[i].t <= currentTime) {
curName = toneChanges[i].name;
} else {
nextChange = toneChanges[i];
break;
}
}
}
if (!curName && !nextChange) return 0;
let nextName = '';
let nextCountdown = '';
if (nextChange) {
const dt = nextChange.t - currentTime;
nextName = nextChange.name;
nextCountdown = dt > 10
? 'in ' + Math.round(dt) + 's'
: 'in ' + Math.max(0, dt).toFixed(1) + 's';
}
const sizeF = 0.65 + 0.85 * sizeSlider;
const baseH = Math.max(34, Math.min(72, Math.round(canvasH * 0.085 * sizeF)));
const PAD_X = Math.round(baseH * 0.45);
const PAD_Y = Math.round(baseH * 0.20);
let textScale = 1.0;
const baseLineH = Math.round(baseH * 0.46);
const baseNameSize = Math.round(baseH * 0.36);
const baseTagSize = Math.round(baseH * 0.24);
const baseTagGap = Math.round(baseH * 0.14);
const TAG_CUR = 'Tone:';
const TAG_NEXT = 'Next:';
ctx.save();
ctx.font = `${baseTagSize}px sans-serif`;
const tagCurWBase = ctx.measureText(TAG_CUR).width;
const tagNextWBase = ctx.measureText(TAG_NEXT).width;
const countdownWBase = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${baseNameSize}px sans-serif`;
const curNameWBase = curName ? ctx.measureText(curName).width : 0;
const nextNameWBase = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurWBase = curName ? tagCurWBase + baseTagGap + curNameWBase : 0;
const lineNextWBase = nextName
? tagNextWBase + baseTagGap + nextNameWBase
+ (nextCountdown ? baseTagGap + countdownWBase : 0)
: 0;
const contentWBase = Math.max(lineCurWBase, lineNextWBase);
const numLines = (curName ? 1 : 0) + (nextName ? 1 : 0);
if (numLines === 0) return 0;
const maxBoxW = Math.max(40, canvasW - 16);
const availContentW = Math.max(1, maxBoxW - PAD_X * 2);
if (contentWBase > availContentW) {
textScale = Math.max(0.55, availContentW / contentWBase);
}
const lineH = Math.max(1, Math.round(baseLineH * textScale));
const nameSize = Math.max(1, Math.round(baseNameSize * textScale));
const tagSize = Math.max(1, Math.round(baseTagSize * textScale));
const TAG_GAP = Math.max(1, Math.round(baseTagGap * textScale));
ctx.save();
ctx.font = `${tagSize}px sans-serif`;
const tagCurW = ctx.measureText(TAG_CUR).width;
const tagNextW = ctx.measureText(TAG_NEXT).width;
const countdownW = nextCountdown ? ctx.measureText(nextCountdown).width : 0;
ctx.font = `bold ${nameSize}px sans-serif`;
const curNameW = curName ? ctx.measureText(curName).width : 0;
const nextNameW = nextName ? ctx.measureText(nextName).width : 0;
ctx.restore();
const lineCurW = curName ? tagCurW + TAG_GAP + curNameW : 0;
const lineNextW = nextName
? tagNextW + TAG_GAP + nextNameW + (nextCountdown ? TAG_GAP + countdownW : 0)
: 0;
const contentW = Math.max(lineCurW, lineNextW);
const boxW = Math.min(maxBoxW, Math.round(contentW + PAD_X * 2));
const boxH = Math.round(numLines * lineH + PAD_Y * 2);
const E = Math.round(baseH * 0.25);
const TOP_Y = Math.round(Math.max(E + canvasH * 0.06, lyricsBottom + E));
let bx, by;
if (position === 'tr') { bx = canvasW - boxW - E; by = TOP_Y + stackOffset; }
else if (position === 'bl') { bx = E; by = canvasH - boxH - E - stackOffset; }
else if (position === 'br') { bx = canvasW - boxW - E; by = canvasH - boxH - E - stackOffset; }
else { bx = E; by = TOP_Y + stackOffset; } // 'tl' default
bx = Math.max(0, Math.min(canvasW - boxW, bx));
by = Math.max(0, Math.min(canvasH - boxH, by));
if (lyricsBottom > 0 && by < lyricsBottom) return 0;
ctx.save();
ctx.fillStyle = 'rgba(8, 14, 22, 0.88)';
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.fill();
ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.roundRect(bx, by, boxW, boxH, 7); ctx.stroke();
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
const lineX = bx + PAD_X;
let lineY = by + PAD_Y + lineH / 2;
const TAG_COLOR = 'rgba(180,190,205,0.85)';
const NAME_COLOR = '#ff9a3c'; // amber — distinct from section cyan
const TIME_COLOR = 'rgba(220,225,235,0.9)';
if (curName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_CUR, lineX, lineY);
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(curName, lineX + tagCurW + TAG_GAP, lineY);
lineY += lineH;
}
if (nextName) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TAG_COLOR;
ctx.fillText(TAG_NEXT, lineX, lineY);
const nextX = lineX + tagNextW + TAG_GAP;
ctx.font = `bold ${nameSize}px sans-serif`;
ctx.fillStyle = NAME_COLOR;
ctx.fillText(nextName, nextX, lineY);
if (nextCountdown) {
ctx.font = `${tagSize}px sans-serif`;
ctx.fillStyle = TIME_COLOR;
ctx.fillText(nextCountdown, nextX + nextNameW + TAG_GAP, lineY);
}
}
ctx.restore();
return boxH;
}
function drawLyrics(lyrics, currentTime, ctx, W, H) {
if (!lyrics._lines) {
const lines = [];
let line = null, word = null;
const flushWord = () => { if (word && word.length) line.words.push(word); word = null; };
const flushLine = () => { flushWord(); if (line && line.words.length) lines.push(line); line = null; };
for (let i = 0; i < lyrics.length; i++) {
const l = lyrics[i];
const raw = l.w || '';
const endsLine = raw.endsWith('+');
const continuesWord = raw.endsWith('-');
if (line && i > 0 && l.t - (lyrics[i - 1].t + lyrics[i - 1].d) > 4.0) flushLine();
if (!line) line = { words: [], start: l.t, end: l.t + l.d };
if (!word) word = [];
word.push(l);
line.end = Math.max(line.end, l.t + l.d);
if (!continuesWord) flushWord();
if (endsLine) flushLine();
}
flushLine();
lyrics._lines = lines;
}
const allLines = lyrics._lines;
if (!allLines.length) return 0;
let currentIdx = -1;
for (let i = 0; i < allLines.length; i++) {
if (allLines[i].start <= currentTime) currentIdx = i;
else break;
}
if (currentIdx === -1) {
if (allLines[0].start - currentTime > 2.0) return 0;
currentIdx = 0;
}
const currentLine = allLines[currentIdx];
const nextLine = allLines[currentIdx + 1] || null;
const gapToNext = nextLine ? (nextLine.start - currentLine.end) : Infinity;
if (currentTime > currentLine.end + 0.5 && gapToNext > 3.0) return 0;
const linesToShow = [currentLine];
if (nextLine && gapToNext <= 3.0) linesToShow.push(nextLine);
const fontSize = Math.max(18, H * 0.028) | 0;
const lineY = H * 0.04;
const sylText = s => { const t = s.w || ''; return (t.endsWith('+') || t.endsWith('-')) ? t.slice(0, -1) : t; };
ctx.font = `bold ${fontSize}px sans-serif`;
let rows, spaceWidth, bgWidth;
const _lc = _lyrRowsCache;
if (_lc && _lc.lyricsRef === lyrics && _lc.idx === currentIdx
&& _lc.shown === linesToShow.length
&& _lc.fontSize === fontSize && _lc.W === W) {
rows = _lc.rows; spaceWidth = _lc.spaceWidth; bgWidth = _lc.bgWidth;
} else {
spaceWidth = ctx.measureText(' ').width;
const maxWidth = W * 0.8;
rows = [];
for (const authoredLine of linesToShow) {
let row = [], rowWidth = 0;
for (const wordSyls of authoredLine.words) {
const parts = [];
let wordWidth = 0;
for (const s of wordSyls) {
const text = sylText(s);
const w = ctx.measureText(text).width;
parts.push({ syl: s, text, width: w });
wordWidth += w;
}
const advance = wordWidth + spaceWidth;
if (row.length > 0 && rowWidth + advance > maxWidth) { rows.push(row); row = []; rowWidth = 0; }
row.push({ parts, advance });
rowWidth += advance;
}
if (row.length) rows.push(row);
}
bgWidth = 0;
for (const row of rows) {
const rw = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
if (rw > bgWidth) bgWidth = rw;
}
bgWidth = Math.min(bgWidth + 30, W * 0.85);
_lyrRowsCache = {
lyricsRef: lyrics, idx: currentIdx,
shown: linesToShow.length, fontSize, W,
rows, spaceWidth, bgWidth,
};
}
const rowHeight = fontSize + 6;
const totalHeight = rows.length * rowHeight + 10;
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.beginPath();
const bx = W / 2 - bgWidth / 2, by = lineY - 4, br = 8;
ctx.moveTo(bx + br, by); ctx.lineTo(bx + bgWidth - br, by);
ctx.quadraticCurveTo(bx + bgWidth, by, bx + bgWidth, by + br);
ctx.lineTo(bx + bgWidth, by + totalHeight - br);
ctx.quadraticCurveTo(bx + bgWidth, by + totalHeight, bx + bgWidth - br, by + totalHeight);
ctx.lineTo(bx + br, by + totalHeight);
ctx.quadraticCurveTo(bx, by + totalHeight, bx, by + totalHeight - br);
ctx.lineTo(bx, by + br);
ctx.quadraticCurveTo(bx, by, bx + br, by);
ctx.closePath();
ctx.fill();
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
for (let r = 0; r < rows.length; r++) {
const row = rows[r];
const rowWidth = row.reduce((s, w) => s + w.advance, 0) - spaceWidth;
let xPos = W / 2 - rowWidth / 2;
const yPos = lineY + r * rowHeight + 2;
for (const w of row) {
for (const part of w.parts) {
const l = part.syl;
const isActive = currentTime >= l.t && currentTime < l.t + l.d;
const isPast = currentTime >= l.t + l.d;
ctx.fillStyle = isActive ? '#4ae0ff' : isPast ? '#8899aa' : '#556677';
ctx.font = `${isActive ? 'bold' : 'normal'} ${fontSize}px sans-serif`;
ctx.fillText(part.text, xPos, yPos);
xPos += part.width;
}
xPos += spaceWidth;
}
}
// Return the actual bottom Y of the rendered background box so callers
// (e.g. drawChordDiagram) can avoid overlapping it.
return Math.round(by + totalHeight);
}
return {
drawChordDiagram,
_drawDiagramCached,
drawSectionHud,
drawToneHud,
drawLyrics,
};
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-297
View File
@@ -1,297 +0,0 @@
// h3d-carve-10: K-section (score FX) extracted from screen.js.
// VERBATIM-MOVE: function bodies are byte-for-byte identical to their
// screen.js originals except for 7 DI-rewires (see inline // DI: comments).
// No logic changes, no new guards, no structural additions.
//
// Beyond-subst changes (all mechanical DI rewires):
// 1. _fxSpawnPop: _ndFrameNowMs → getNdFrameNowMs()
// 2. drawScoreFx: cam → aliased const cam = getCam()
// 3. drawScoreFx: _probe → aliased const _probe = getProbe()
// 4. drawScoreFx: _ndFrameNowMs → getNdFrameNowMs()
// 5. drawScoreFx: nStr → getNStr()
// 6. drawScoreFx: curX → getCurX()
// 7. fxInit: highwayCanvas (closure) → getHighwayCanvas()
export function createScoreFx({ getHighwayCanvas, getNdFrameNowMs, getCam, getProbe, getNStr, getCurX, sY }) {
// ── Score FX (notedetect game-scoring layer, notedetect ≥1.13) ──
// Two channels: (1) per-note "+N" score pops, sourced from the
// note-state provider's new { points, mult, popKey } fields at the
// moment a gem's verdict lands; (2) session-level bursts/pulses from
// the new `notedetect:fx` event (streak milestones, multiplier tier
// changes, streak breaks). Everything renders on the 2D overlay
// canvas (same layer as drawNotedetectLabels) — no Three.js objects,
// no txtMat() cache entries, nothing to dispose. Pools are fixed-
// size slot arrays created once per factory instance; when all slots
// are busy a new effect is simply dropped.
const _FX_POP_LIFE_MS = 700;
const _FX_BURST_LIFE_MS = 900;
const _FX_BURST_N = 36;
const _fxPops = Array.from({ length: 24 }, () => (
{ active: false, x: 0, y: 0, z: 0, bornMs: 0, text: '', mult: 1 }
));
const _fxBursts = Array.from({ length: 4 }, () => ({
active: false, bornMs: 0,
px: new Float32Array(_FX_BURST_N), py: new Float32Array(_FX_BURST_N),
vx: new Float32Array(_FX_BURST_N), vy: new Float32Array(_FX_BURST_N),
}));
// popKey -> expiry ms. Dedupes pops (chord members share the chord's
// popKey; sustains keep returning points for the whole glow window).
const _fxSeen = new Map();
let _fxOnFx = null; // notedetect:fx listener (window)
let _fxOnSkin = null; // notedetect:skin bus listener
// Generation counter: bumped by teardown() so the deferred window-
// copy fallback (a zero-delay task the listener removal can't cancel)
// bails instead of re-arming ring/burst state after teardown — or,
// worse, leaking a stale event into a subsequent init's fresh state.
let _fxGen = 0;
let _fxLastFxDetail = null; // reference dedup: window + instanceRoot dispatches share one detail
// Details seen via element-scoped (bubbled) dispatch. A WeakSet, not a
// single slot: one judged hit can emit several fx in the same task
// (milestone + multiplier tier-up), and the deferred window-copy
// fallback for the FIRST must still see that its element copy arrived
// after the SECOND overwrote any last-detail slot. GC reclaims
// entries once notedetect drops the detail objects.
let _fxElemSeen = new WeakSet();
let _fxRingMs = -1e9; // multiplier ring-pulse anchor
let _fxRingMult = 1;
let _fxBreakMs = -1e9; // streak-break flicker anchor
// Canvas-side palette per notedetect skin (mirrors the accents in
// notedetect's assets/plugin.css; fonts are document-loaded by that
// stylesheet so the overlay canvas can use the family names).
const _FX_PALETTES = {
neon: { accent: '#00f0ff', accent2: '#ff2ec4', miss: '#ff4444', font: 'Orbitron' },
esports: { accent: '#e8b43a', accent2: '#f5f5f4', miss: '#f87171', font: 'Rajdhani' },
metal: { accent: '#ffb347', accent2: '#ff6b35', miss: '#ef4444', font: 'Russo One' },
};
let _fxPalette = _FX_PALETTES.neon;
function _fxResolvePalette() {
let skin = null;
try { skin = localStorage.getItem('feedBack_notedetect_skin'); } catch (e) {}
_fxPalette = _FX_PALETTES[skin] || _FX_PALETTES.neon;
}
function _fxSpawnPop(popKey, points, mult, x, y, z) {
if (_fxSeen.has(popKey)) return;
const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs
_fxSeen.set(popKey, nowMs + 4000);
for (let i = 0; i < _fxPops.length; i++) {
const p = _fxPops[i];
if (p.active) continue;
p.active = true;
p.x = x; p.y = y; p.z = z;
p.bornMs = nowMs;
p.text = '+' + points;
p.mult = mult || 1;
return;
}
}
function _fxSpawnBurst(nowMs) {
for (let i = 0; i < _fxBursts.length; i++) {
const b = _fxBursts[i];
if (b.active) continue;
b.active = true;
b.bornMs = nowMs;
for (let j = 0; j < _FX_BURST_N; j++) {
const a = (j / _FX_BURST_N) * Math.PI * 2;
const sp = 2 + (j % 5) * 0.8;
b.px[j] = 0; b.py[j] = 0;
b.vx[j] = Math.cos(a) * sp;
b.vy[j] = Math.sin(a) * sp - 1.2;
}
return;
}
}
function _fxHandle(d) {
// Reference dedup — notedetect dispatches the SAME detail object
// on window and on its instanceRoot; whichever arrives first wins.
if (d === _fxLastFxDetail) return;
_fxLastFxDetail = d;
const nowMs = performance.now();
if (d.fxType === 'milestone') {
_fxSpawnBurst(nowMs);
} else if (d.fxType === 'multiplier' && d.mult > (d.prevMult || 1)) {
_fxRingMs = nowMs;
_fxRingMult = d.mult;
} else if (d.fxType === 'streakBreak') {
_fxBreakMs = nowMs;
}
}
// Score FX overlay pass — "+N" pops rising off their gems, milestone
// particle bursts / multiplier ring-pulses / streak-break flickers
// anchored on the strike line. Same overlay layer + projection
// pattern as drawNotedetectLabels; costs one early-out when nothing
// is active.
function drawScoreFx(ctx, W, H) {
const cam = getCam(); // DI: cam
const _probe = getProbe(); // DI: _probe
if (!cam || !_probe) return;
const nowMs = getNdFrameNowMs() || performance.now(); // DI: _ndFrameNowMs
// TTL-prune the pop dedup keys (bounded: only notes hit in the
// last few seconds).
if (_fxSeen.size) {
for (const [k, exp] of _fxSeen) {
if (exp <= nowMs) _fxSeen.delete(k);
}
}
let anyPop = false;
for (let i = 0; i < _fxPops.length; i++) {
if (_fxPops[i].active) { anyPop = true; break; }
}
let anyBurst = false;
for (let i = 0; i < _fxBursts.length; i++) {
if (_fxBursts[i].active) { anyBurst = true; break; }
}
const ringAge = nowMs - _fxRingMs;
const breakAge = nowMs - _fxBreakMs;
if (!anyPop && !anyBurst && ringAge >= 600 && breakAge >= 350) return;
const pal = _fxPalette;
ctx.save();
// Streak-break flicker: brief red wash over the whole panel.
if (breakAge < 350) {
const a = 0.10 * (1 - breakAge / 350);
ctx.fillStyle = pal.miss;
ctx.globalAlpha = a;
ctx.fillRect(0, 0, W, H);
ctx.globalAlpha = 1;
}
// Strike-line center in screen px — anchor for bursts + pulses.
let cx = W / 2, cy = H * 0.72, centerOk = false;
{
const fretMidY = (sY(0) + sY(getNStr() - 1)) / 2; // DI: nStr
_probe.set(getCurX(), fretMidY, 0); // DI: curX
_probe.project(cam);
if (_probe.z >= -1 && _probe.z <= 1) {
cx = (_probe.x * 0.5 + 0.5) * W;
cy = (-_probe.y * 0.5 + 0.5) * H;
centerOk = true;
}
}
// Multiplier ring-pulse: one expanding ring on tier-up; the ×4
// tier pulses in the secondary accent like the HUD badge.
if (centerOk && ringAge < 600) {
const t = ringAge / 600;
const ease = 1 - Math.pow(1 - t, 2);
ctx.beginPath();
ctx.arc(cx, cy, 20 + ease * Math.min(W, H) * 0.28, 0, Math.PI * 2);
ctx.strokeStyle = _fxRingMult >= 4 ? pal.accent2 : pal.accent;
ctx.globalAlpha = 0.6 * (1 - t);
ctx.lineWidth = 3;
ctx.stroke();
ctx.globalAlpha = 1;
}
// Milestone bursts.
if (anyBurst && centerOk) {
for (let i = 0; i < _fxBursts.length; i++) {
const b = _fxBursts[i];
if (!b.active) continue;
const age = nowMs - b.bornMs;
if (age >= _FX_BURST_LIFE_MS) { b.active = false; continue; }
const t = age / _FX_BURST_LIFE_MS;
ctx.globalAlpha = 1 - t;
for (let j = 0; j < _FX_BURST_N; j++) {
b.px[j] += b.vx[j];
b.py[j] += b.vy[j];
b.vy[j] += 0.08;
ctx.fillStyle = (j & 1) ? pal.accent : pal.accent2;
ctx.fillRect(cx + b.px[j] - 2, cy + b.py[j] - 2, 4, 4);
}
ctx.globalAlpha = 1;
}
}
// "+N" pops: rise off the gem and fade over the back half.
if (anyPop) {
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (let i = 0; i < _fxPops.length; i++) {
const p = _fxPops[i];
if (!p.active) continue;
const age = nowMs - p.bornMs;
if (age >= _FX_POP_LIFE_MS) { p.active = false; continue; }
_probe.set(p.x, p.y, p.z);
_probe.project(cam);
if (_probe.z < -1 || _probe.z > 1) continue;
const t = age / _FX_POP_LIFE_MS;
const sx = (_probe.x * 0.5 + 0.5) * W;
const sy2 = (-_probe.y * 0.5 + 0.5) * H - t * 30;
ctx.globalAlpha = t < 0.4 ? 1 : 1 - (t - 0.4) / 0.6;
ctx.font = `bold ${13 + (p.mult - 1) * 2}px '${pal.font}', sans-serif`;
ctx.lineWidth = 4;
ctx.strokeStyle = 'rgba(0,0,0,0.8)';
ctx.strokeText(p.text, sx, sy2);
ctx.fillStyle = pal.accent;
ctx.fillText(p.text, sx, sy2);
}
ctx.globalAlpha = 1;
}
ctx.restore();
}
// Score FX (notedetect ≥1.13). notedetect dispatches each fx
// detail object twice in the same task: first explicitly on
// window (unscoped), then as a bubbling CustomEvent from its
// per-panel instanceRoot (scoped). Element-targeted copies are
// authoritative — accept only the ones whose root lives in this
// panel's container. The window copy is DEFERRED a task: by the
// time it runs, the element copy (same detail reference) has
// either arrived — making the window copy a duplicate to drop —
// or it never will (detector root not attached to the DOM), in
// which case the window copy is the compat fallback. This keeps
// splitscreen panels from rendering each other's FX even for
// the first event of a session.
//
// NOTE (verbatim-preserved): fxInit has NO re-entry guard —
// the original screen.js init block had none. Calling fxInit()
// twice double-registers the notedetect:fx listener. The dispatch
// contract says to declare, not fix, this behavior here.
function fxInit() {
_fxResolvePalette();
_fxOnFx = (e) => {
const d = e && e.detail;
if (!d) return;
const t = e.target;
if (t && t.parentElement) {
_fxElemSeen.add(d);
if (!getHighwayCanvas() || !t.parentElement.contains(getHighwayCanvas())) return; // DI: highwayCanvas
_fxHandle(d);
return;
}
const gen = _fxGen;
setTimeout(() => {
if (gen !== _fxGen) return; // torn down (or re-inited) meanwhile
if (_fxElemSeen.has(d)) return;
_fxHandle(d);
}, 0);
};
window.addEventListener('notedetect:fx', _fxOnFx);
if (window.feedBack && typeof window.feedBack.on === 'function'
&& typeof window.feedBack.off === 'function') {
_fxOnSkin = () => _fxResolvePalette();
window.feedBack.on('notedetect:skin', _fxOnSkin);
}
}
function fxTeardown() {
if (_fxOnFx) { window.removeEventListener('notedetect:fx', _fxOnFx); _fxOnFx = null; }
if (window.feedBack && typeof window.feedBack.off === 'function') {
if (_fxOnSkin) { try { window.feedBack.off('notedetect:skin', _fxOnSkin); } catch (e) {} _fxOnSkin = null; }
}
for (const p of _fxPops) p.active = false;
for (const b of _fxBursts) b.active = false;
_fxSeen.clear();
_fxGen++; // invalidate any pending deferred window-copy fallbacks
_fxLastFxDetail = null;
_fxElemSeen = new WeakSet();
_fxRingMs = _fxBreakMs = -1e9;
}
function fxClearSeen() { _fxSeen.clear(); }
return { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx, fxClearSeen };
}
-78
View File
@@ -1,78 +0,0 @@
// 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 };
}
-38
View File
@@ -1,38 +0,0 @@
/**
* Three.js lazy loader h3d-carve-2.
*
* Memoised singleton: the first `loadThree()` call kicks off the import;
* every subsequent call returns the same promise. `T` is a live-binding
* export so the IIFE in screen.js sees the updated reference after the
* promise resolves without any explicit getter call.
*
* Falls back to jsdelivr CDN when the local vendor copy is unavailable
* (air-gapped / static-file layout mismatch / dev origin).
*/
// ── URL constants (mirrors screen.js A-section; never vary at runtime) ──────
const THREE_URL = '/static/vendor/three/three.module.min.js';
const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js';
// ── Memoised loader ───────────────────────────────────────────────────────────
/** Live binding — updated to the Three.js module namespace on first load. */
export let T = null;
let threeLoadPromise = null;
export function loadThree() {
if (!threeLoadPromise) {
threeLoadPromise = import(THREE_URL)
.then(mod => { T = mod; return mod; })
.catch(() => import(THREE_CDN)
.then(mod => { T = mod; return mod; })
.catch(e => {
console.error('[3D-Hwy] Three.js load failed:', e);
threeLoadPromise = null;
throw e;
}));
}
return threeLoadPromise;
}
-148
View File
@@ -1,148 +0,0 @@
/**
* Color utilities, tuning helpers, and splitscreen predicates h3d-carve-3.
*
* All exports are pure functions or compile-time constants; none capture
* factory-scope state. NSTR and MAX_RENDER_STRINGS are compile-time copies
* of the matching IIFE constants (both = 6 for the current 6-entry
* PALETTES.default / S_COL layout).
*/
// ── Compile-time IIFE constant ────────────────────────────────────────────────
// NSTR: standard guitar default (not a palette ceiling — safe as a fixed fact).
// MAX_RENDER_STRINGS is NOT duplicated here; it is the authority of S_COL.length
// in screen.js and flows in via the maxStrings parameter of resolveStringCount
// and _openStringPitchLabelsForTuning. screen.js keeps 1-line delegators that
// supply MAX_RENDER_STRINGS so zero call-sites change.
const NSTR = 6;
// ── Color utilities ───────────────────────────────────────────────────────────
/**
* Parse a CSS hex color string ('#rrggbb', '#rgb', or bare variants) to a
* packed 0xRRGGBB integer. Returns null on any parse failure.
*/
export function _h3dHexToInt(hex) {
if (typeof hex !== 'string') return null;
const t = hex.trim().replace(/^#/, '');
const full = t.length === 3 ? t[0] + t[0] + t[1] + t[1] + t[2] + t[2] : t;
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
return parseInt(full, 16);
}
export function _clampByteI(n) { return n < 0 ? 0 : (n > 255 ? 255 : Math.round(n)); }
export function _darkenInt(hex, factor) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r * factor) << 16) | (_clampByteI(g * factor) << 8) | _clampByteI(b * factor);
}
export function _lightenInt(hex, t) {
const r = (hex >> 16) & 0xff, g = (hex >> 8) & 0xff, b = hex & 0xff;
return (_clampByteI(r + (255 - r) * t) << 16) | (_clampByteI(g + (255 - g) * t) << 8) | _clampByteI(b + (255 - b) * t);
}
// ── String-count resolution ───────────────────────────────────────────────────
/**
* Resolve the string count for the active arrangement. Prefer
* bundle.stringCount (exposed by feedBack core since #93 derived from
* notes/chords/tuning, works for 5-string bass, 7- and 8-string guitar).
* Falls back to arrangement-name detection for older feedBack cores.
* Clamped to MAX_RENDER_STRINGS so a malformed bundle doesn't index past
* the per-string material arrays.
*/
export function resolveStringCount(bundle, maxStrings) {
const sc = bundle && bundle.stringCount;
if (Number.isFinite(sc) && sc >= 1) {
return Math.min(Math.trunc(sc), maxStrings);
}
return /bass/i.test(bundle?.songInfo?.arrangement || '') ? 4 : NSTR;
}
// ── Tuning / pitch-label helpers ──────────────────────────────────────────────
/** Chart-format tuning entries are semitone offsets from instrument standard. */
export const _NOTE_NAMES_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Open-string MIDI (thick → thin), matched to RS string index 0 low.
export const _BASE_OPEN_MIDI_BASS4 = Object.freeze([28, 33, 38, 43]);
export const _BASE_OPEN_MIDI_BASS5 = Object.freeze([23, 28, 33, 38, 43]);
export const _BASE_OPEN_MIDI_GUITAR6 = Object.freeze([40, 45, 50, 55, 59, 64]);
export const _BASE_OPEN_MIDI_GUITAR7 = Object.freeze([35, 40, 45, 50, 55, 59, 64]);
// F#/B/E standard extension — low string is a fifth below RS 7-string low B.
export const _BASE_OPEN_MIDI_GUITAR8 = Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]);
export function _baseOpenStringMidis(sc, arrangement) {
const isBass = /bass/i.test(arrangement || '');
if (sc === 4 && isBass) return _BASE_OPEN_MIDI_BASS4.slice();
if (sc === 4) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 4);
if (sc === 5 && isBass) return _BASE_OPEN_MIDI_BASS5.slice();
if (sc === 5) return _BASE_OPEN_MIDI_GUITAR6.slice(0, 5);
if (sc === 7) return _BASE_OPEN_MIDI_GUITAR7.slice();
if (sc === 8) return _BASE_OPEN_MIDI_GUITAR8.slice();
if (Number.isFinite(sc) && sc > 8) {
const out = Array.from(_BASE_OPEN_MIDI_GUITAR8);
let last = out[out.length - 1];
while (out.length < sc) {
last += 5;
out.push(last);
}
return out.slice(0, sc);
}
const g6 = _BASE_OPEN_MIDI_GUITAR6.slice();
if (Number.isFinite(sc) && sc < 6 && sc >= 1) return g6.slice(0, sc);
return g6;
}
export function _midiToPitchLabel(midi) {
const m = Math.round(midi);
const octave = Math.floor(m / 12) - 1;
const n = _NOTE_NAMES_SHARP[(m % 12 + 12) % 12];
return n + octave;
}
/**
* @param {object} bundle Highway render bundle (tuning, capo, stringCount)
* @param {object} songInfo WS song_info blob (arrangement, tuning, capo)
* @param {number} nEffective String count clamped like nStr / resolveStringCount
*/
export function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective, maxStrings) {
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), maxStrings) : resolveStringCount(bundle, maxStrings);
// bundle first: chart-transform substitutes tuning/capo there, while
// songInfo keeps the chart's originals by contract. A malformed
// (non-array) bundle.tuning falls back to songInfo instead of
// blanking the labels.
let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning);
let cap = bundle.capo;
cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0);
if (!Array.isArray(tuning)) tuning = [];
const base = _baseOpenStringMidis(n, songInfo?.arrangement);
const labels = [];
for (let s = 0; s < n; s++) {
const offRaw = tuning[s];
const off = Number.isFinite(offRaw) ? offRaw : 0;
const midi = (base[s] !== undefined ? base[s] : 40) + off + cap;
labels.push(_midiToPitchLabel(midi));
}
return labels;
}
// ── Splitscreen predicates ────────────────────────────────────────────────────
// window.feedBackSplitscreen is read live each call — never captured at
// module or factory scope.
export function _ssActive() {
const ss = window.feedBackSplitscreen;
if (!ss || typeof ss.isActive !== 'function' || !ss.isActive()) return false;
return typeof ss.isCanvasFocused === 'function'
&& typeof ss.onFocusChange === 'function'
&& typeof ss.offFocusChange === 'function';
}
export function _ssIsCanvasFocused(highwayCanvas) {
const ss = window.feedBackSplitscreen;
if (!_ssActive()) return true;
return !!(ss && typeof ss.isCanvasFocused === 'function' &&
ss.isCanvasFocused(highwayCanvas));
}
@@ -15,10 +15,11 @@
// style reads `intensity`, and none of them read audio bands under // style reads `intensity`, and none of them read audio bands under
// Butterchurn, so a live-looking knob that does nothing is a real bug. // Butterchurn, so a live-looking knob that does nothing is a real bug.
// //
// h3d-carve-5: the _pc* block was moved from screen.js to src/bg-control.js. // screen.js is a single ~16k-line IIFE, so the control cannot be imported. The
// load() now evaluates the factory module (stripping the `export` keyword), // self-contained `_pc*` block is sliced out of the real source and evaluated
// calls createBgControl({DI}) with stubbed deps, and injects test-only getters // with its few collaborators stubbed (BG_STYLE_IDS, _bgReadSetting,
// into the return object so the private state vars remain observable. // _bgSubscribe/_bgUnsubscribe). The slice markers are asserted before use: move
// or rename the block and this fails loudly rather than testing nothing.
const { test } = require('node:test'); const { test } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
@@ -26,8 +27,10 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const vm = require('node:vm'); const vm = require('node:vm');
const SCREEN_JS = path.join(__dirname, '..', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
const BG_CONTROL_JS = path.join(__dirname, '..', 'src', 'bg-control.js'); const START = ' const _PC_LABELS = {';
const END_CRLF = ' /* ======================================================================\r\n * Factory';
const END_LF = ' /* ======================================================================\n * Factory';
// What each style is expected to consume, derived by reading the BG_STYLES // What each style is expected to consume, derived by reading the BG_STYLES
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES // bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
@@ -99,24 +102,14 @@ function makeDom() {
} }
function load({ store: initialStore } = {}) { function load({ store: initialStore } = {}) {
// h3d-carve-5: load from src/bg-control.js (factory module) instead of const src = fs.readFileSync(SCREEN_JS, 'utf8');
// slicing screen.js. Strip `export` for vm eval; inject test-only getters const start = src.indexOf(START);
// into the return object so private _pc* state vars remain observable. assert.notEqual(start, -1, 'could not find the _PC_LABELS marker in screen.js');
const bgSrc = fs.readFileSync(BG_CONTROL_JS, 'utf8'); let end = src.indexOf(END_CRLF);
const stripped = bgSrc.replace(/^export\s+/gm, ''); if (end === -1) end = src.indexOf(END_LF);
// Augment the factory's return with accessor getters for private state so assert.notEqual(end, -1, 'could not find the Factory banner marker in screen.js');
// all existing test assertions (api.el, api.sel, api.refs, ...) keep working. assert.ok(end > start, 'slice markers found out of order in screen.js');
const instrumented = stripped.replace( const block = src.slice(start, end);
/return\s*\{\s*_pcAcquire\s*,\s*_pcRelease\s*\}/,
'return { _pcAcquire, _pcRelease,'
+ ' get el() { return _pcEl; },'
+ ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } }',
);
assert.notEqual(instrumented, stripped, 'return-augmentation anchor not found in bg-control.js');
const dom = makeDom(); const dom = makeDom();
const store = Object.assign({ const store = Object.assign({
@@ -133,12 +126,14 @@ function load({ store: initialStore } = {}) {
const writes = []; const writes = [];
const timers = []; const timers = [];
// DI dependencies as vm-globals. Tests mutate sandbox._venueSceneOverride
// directly; getVenueSceneOverride in the factory call reads it via the vm global.
const sandbox = { const sandbox = {
console, console,
BG_STYLE_IDS, BG_STYLE_IDS,
// Module-scope in screen.js; the _pc* block reads it to resolve the
// effective style under the Venue override. Tests flip it via
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
_venueSceneOverride: false, _venueSceneOverride: false,
_bgReadSetting: (_panelKey, key) => store[key],
_bgReadGlobal: (key) => store[key], _bgReadGlobal: (key) => store[key],
_bgSubscribe: (fn) => listeners.add(fn), _bgSubscribe: (fn) => listeners.add(fn),
_bgUnsubscribe: (fn) => listeners.delete(fn), _bgUnsubscribe: (fn) => listeners.delete(fn),
@@ -170,18 +165,18 @@ function load({ store: initialStore } = {}) {
}, },
}; };
sandbox.globalThis = sandbox; sandbox.globalThis = sandbox;
vm.createContext(sandbox);
// Step 1: define createBgControl in the vm context. const api = vm.runInNewContext(
vm.runInContext(instrumented, sandbox); block
+ '\n({ _pcAcquire, _pcRelease,'
// Step 2: call the factory; DI values are vm-globals so the call names them directly. + ' get el() { return _pcEl; },'
const api = vm.runInContext( + ' get sel() { return _pcSel; },'
'createBgControl({ BG_STYLE_IDS, _bgReadGlobal, _bgSubscribe, _bgUnsubscribe,' + ' get react() { return _pcReactive; },'
+ ' getVenueSceneOverride: () => _venueSceneOverride })', + ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } })',
sandbox, sandbox,
); );
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn()); const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
const screenHooks = () => (bus['screen:changed'] || []).length; const screenHooks = () => (bus['screen:changed'] || []).length;
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks }; return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
+6 -50
View File
@@ -23,7 +23,6 @@
const INSTRUMENTS = { const INSTRUMENTS = {
guitar: { label: 'Guitar', mode: 'audio' }, guitar: { label: 'Guitar', mode: 'audio' },
bass: { label: 'Bass', mode: 'audio' }, bass: { label: 'Bass', mode: 'audio' },
vocals: { label: 'Vocals', mode: 'audio' },
keys: { label: 'Keys / Piano', mode: 'midi' }, keys: { label: 'Keys / Piano', mode: 'midi' },
piano: { label: 'Keys / Piano', mode: 'midi' }, piano: { label: 'Keys / Piano', mode: 'midi' },
drums: { label: 'Drums', mode: 'midi' }, drums: { label: 'Drums', mode: 'midi' },
@@ -134,25 +133,16 @@
const opts2 = sources.map((s) => const opts2 = sources.map((s) =>
'<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join(''); '<option value="' + esc(s.logicalSourceKey || s.sourceId || '') + '"' + (s.selected ? ' selected' : '') + '>' + esc(s.label || 'Input') + '</option>').join('');
const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function'); const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function');
const hasVocalCal = inst === 'vocals' && !!(window.feedBack && window.feedBack.vocalCalibration &&
window.feedBack.vocalCalibration.version === 1 &&
typeof window.feedBack.vocalCalibration.launch === 'function');
// For vocals: "Calibrate" iff vocal-cal facade present; "Continue" otherwise.
// For guitar/bass: "Calibrate" iff noteDetect present; "Continue" otherwise.
const canCalibrate = inst === 'vocals' ? hasVocalCal : hasDetector;
const notLoadedNotice = inst === 'vocals'
? (hasVocalCal ? '' : '<p class="text-xs text-fb-textDim mt-3">Vocal calibration isnt available yet — you can set it up later from the player.</p>')
: (hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isnt loaded here — you can calibrate later from the player.</p>');
const body = const body =
'<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' + '<p class="text-sm text-fb-textDim">Pick your audio input, then run the calibration to set levels, channel and latency.</p>' +
(sources.length (sources.length
? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' + ? '<label class="block text-xs uppercase tracking-wider text-fb-textDim mt-3 mb-1">Audio input</label>' +
'<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>' '<select data-is-audio class="w-full bg-gray-800/50 border border-gray-700 rounded-md px-2 py-1.5 text-sm text-fb-text outline-none">' + opts2 + '</select>'
: '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') + : '<p class="text-sm text-fb-accent mt-2">No audio input detected yet — plug in your interface, or skip and set this up later.</p>') +
notLoadedNotice; (hasDetector ? '' : '<p class="text-xs text-fb-textDim mt-3">The note detector isnt loaded here — you can calibrate later from the player.</p>');
const foot = const foot =
'<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' + '<button type="button" data-is-cal class="bg-fb-primary hover:bg-fb-primaryHi text-white px-5 py-2 rounded-md font-medium">' +
(canCalibrate ? 'Calibrate' : 'Continue') + '</button>'; (hasDetector ? 'Calibrate' : 'Continue') + '</button>';
shell(inst, body, foot); shell(inst, body, foot);
const sel = host.querySelector('[data-is-audio]'); const sel = host.querySelector('[data-is-audio]');
@@ -172,42 +162,8 @@
// Tell the tuner tables / note_detect which instrument this is. // Tell the tuner tables / note_detect which instrument this is.
try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {} try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {}
const calBtn = host.querySelector('[data-is-cal]'); host.querySelector('[data-is-cal]').addEventListener('click', () => {
let _advancing = false; // double-click guard for the auto-advance fallback if (hasDetector) {
calBtn.addEventListener('click', () => {
if (inst === 'vocals') {
// Vocals calibration is handled by the vocal-highway plugin's
// facade. Guard: if the facade is absent (plugin disabled or
// not yet loaded), mark done with a notice and continue.
const vc = window.feedBack && window.feedBack.vocalCalibration;
if (vc && vc.version === 1 && typeof vc.launch === 'function') {
const ov = document.getElementById('input-setup-overlay');
const prevDisplay = ov ? ov.style.display : '';
if (ov) ov.style.display = 'none';
const restore = () => {
const o = document.getElementById('input-setup-overlay');
if (o) o.style.display = prevDisplay;
};
vc.launch({
requester: 'input_setup',
onDone: (_result) => { restore(); advance(inst, true); },
onCancel: () => { restore(); /* stay on panel; user can skip or retry */ },
});
} else {
// ponytail: vocal-highway not loaded — mark done so wizard doesn't hang
if (_advancing) return; // double-click guard: one advance per panel
_advancing = true;
calBtn.disabled = true;
const body = host.querySelector('[data-is-body]');
if (body) body.innerHTML = '<p class="text-sm text-fb-textDim">Vocal calibration will be available once the Vocal Highway plugin is enabled. Continuing…</p>';
// Store timer id so advance() (via _activeCleanup) can cancel it
// if the user hits Skip before the 1800ms fires — prevents the
// stale timer from double-advancing the wizard and dropping the
// next instrument from both completed and skipped.
const _timerId = setTimeout(() => advance(inst, true), 1800);
_activeCleanup = () => clearTimeout(_timerId);
}
} else if (hasDetector) {
// Hide our own full-screen overlay while note_detect's // Hide our own full-screen overlay while note_detect's
// Calibration Wizard runs on top. That wizard goes // Calibration Wizard runs on top. That wizard goes
// transparent (pointer-events:none) when it minimizes to // transparent (pointer-events:none) when it minimizes to
@@ -396,7 +352,7 @@
// Settings-panel re-entry (settings.html "Set up input devices" button). // Settings-panel re-entry (settings.html "Set up input devices" button).
// Re-runs the wizard for the player's selected instrument paths, falling // Re-runs the wizard for the player's selected instrument paths, falling
// back to all instruments when progression isnt available. // back to all instruments when progression isn't available.
window._inputSetupRelaunch = async function () { window._inputSetupRelaunch = async function () {
let instruments = []; let instruments = [];
try { try {
@@ -407,7 +363,7 @@
instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean); instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean);
} }
} catch (_) { /* offline — fall back below */ } } catch (_) { /* offline — fall back below */ }
if (!instruments.length) instruments = ['guitar', 'bass', 'vocals', 'keys', 'drums']; if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums'];
launch(instruments); launch(instruments);
}; };
})(); })();
+1 -1
View File
@@ -1136,7 +1136,7 @@ def trigger_full_rescan():
# delete_missing() prunes anything genuinely gone at the end. # delete_missing() prunes anything genuinely gone at the end.
meta_db.conn.execute("UPDATE songs SET mtime = -1") meta_db.conn.execute("UPDATE songs SET mtime = -1")
meta_db.conn.commit() meta_db.conn.commit()
if not scan.kick_scan(force=True, allow_mass_prune=True): if not scan.kick_scan(force=True):
return {"message": "Scan already in progress"} return {"message": "Scan already in progress"}
return {"message": "Full rescan started"} return {"message": "Full rescan started"}
-335
View File
@@ -1,335 +0,0 @@
/**
* Tests for career-gig-tuning interstitial logic (feedBack career-gig-tuning).
*
* Failure inputs:
* - pref='specific' + first song no interstitial (specific never needs a tune pause)
* - pref='any' + first song interstitial fires
* - pref='any' + same tuning no interstitial between songs
* - pref='any' + tuning diff interstitial fires
* - holdAutoplay absent interstitial gracefully skipped
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..', '..');
const SCREEN_JS = fs.readFileSync(
path.join(ROOT, 'plugins', 'career', 'screen.js'), 'utf8'
);
// ---------------------------------------------------------------------------
// VM harness
// ---------------------------------------------------------------------------
function makeCtx(opts = {}) {
const holdReleaseCalled = { v: false };
const holdSettleCalled = { v: false };
const feedBackBase = {
on: () => {},
emit: () => {},
holdAutoplay: opts.noHoldAutoplay ? undefined : function () {
const release = function () { holdReleaseCalled.v = true; };
release.settle = function () { holdSettleCalled.v = true; };
return release;
},
};
const ctx = vm.createContext({
window: {},
document: {
getElementById: () => null,
readyState: 'complete',
addEventListener: () => {},
},
localStorage: {
_store: {},
getItem(k) { return this._store[k] != null ? this._store[k] : null; },
setItem(k, v) { this._store[k] = String(v); },
},
clearTimeout: () => {},
setTimeout: (fn, ms) => 42,
fetch: () => Promise.resolve({ ok: false, json: async () => ({}) }),
console,
__holdReleaseCalled: holdReleaseCalled,
__holdSettleCalled: holdSettleCalled,
});
// Set window.feedBack inside the context so script-level refs pick it up
ctx.window.feedBack = feedBackBase;
vm.runInContext(SCREEN_JS, ctx);
return ctx;
}
function setRun(ctx, tuning_pref, songs) {
vm.runInContext(`
window.__careerPassportTest.setGigRun({
idx: 0,
tuning_pref: ${JSON.stringify(tuning_pref)},
songs: ${JSON.stringify(songs)},
});
`, ctx);
}
function get(ctx, expr) {
return vm.runInContext(expr, ctx);
}
function callOnLoading(ctx) {
vm.runInContext('window.__careerPassportTest.onGigSongLoading()', ctx);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('career-gig-tuning interstitial', () => {
test('no hold when gig run is null', () => {
const ctx = makeCtx();
vm.runInContext('window.__careerPassportTest.setGigRun(null)', ctx);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song fires interstitial for pref=any', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song fires interstitial for pref=standard', () => {
const ctx = makeCtx();
setRun(ctx, 'standard', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('first song does NOT fire interstitial for pref=specific:E Standard', () => {
// Failure input: bare 'specific' would pass the old wrong guard `!== 'specific'`
// but is impossible in production. Real value is always 'specific:<name>'.
const ctx = makeCtx();
setRun(ctx, 'specific:E Standard', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('between-songs same tuning: no interstitial', () => {
const ctx = makeCtx();
const songs = [
{ filename: 'a.sloppak', tuning_name: 'E Standard' },
{ filename: 'b.sloppak', tuning_name: 'E Standard' },
];
setRun(ctx, 'any', songs);
vm.runInContext('window.__careerPassportTest.setLastTuning("E Standard")', ctx);
vm.runInContext('window.__careerPassportTest.getGigRun().idx = 1', ctx);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('between-songs tuning change: fires interstitial for pref=any', () => {
const ctx = makeCtx();
const songs = [
{ filename: 'a.sloppak', tuning_name: 'E Standard' },
{ filename: 'b.sloppak', tuning_name: 'Drop D' },
];
setRun(ctx, 'any', songs);
vm.runInContext('window.__careerPassportTest.setLastTuning("E Standard")', ctx);
vm.runInContext('window.__careerPassportTest.getGigRun().idx = 1', ctx);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('lastTuning is updated after onGigSongLoading', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'Drop D' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getLastTuning()'), 'Drop D');
});
test('clearing hold via setTuningHold(null) leaves null', () => {
const ctx = makeCtx();
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.notEqual(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
vm.runInContext('window.__careerPassportTest.setTuningHold(null)', ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
test('holdAutoplay unavailable: no interstitial (graceful skip)', () => {
const ctx = makeCtx({ noHoldAutoplay: true });
setRun(ctx, 'any', [{ filename: 'a.sloppak', tuning_name: 'E Standard' }]);
callOnLoading(ctx);
assert.equal(get(ctx, 'window.__careerPassportTest.getTuningHold()'), null);
});
});
// ---------------------------------------------------------------------------
// bookGig — generation guard (F1) and 404-only revert (F2)
// ---------------------------------------------------------------------------
describe('career-gig-tuning bookGig', () => {
// Helper: make a ctx where bookGig is callable.
// fetch is overridable per-test via ctx.fetch.
function makeBookCtx() {
const ctx = vm.createContext({
window: {},
document: {
getElementById: () => null,
readyState: 'complete',
addEventListener: () => {},
},
localStorage: {
_store: {},
getItem(k) { return this._store[k] != null ? this._store[k] : null; },
setItem(k, v) { this._store[k] = String(v); },
},
clearTimeout: () => {},
setTimeout: () => 42,
fetch: null, // set per test
console,
});
ctx.window.feedBack = { on: () => {}, emit: () => {} };
vm.runInContext(SCREEN_JS, ctx);
// Seed _pp so bookGig can find the passport
vm.runInContext(`
window.__careerPassportTest.setView({
instruments: {
guitar: {
passports: [{ genre_key: 'rock', genre: 'Rock' }]
}
}
});
`, ctx);
return ctx;
}
test('F1: stale response from superseded request is discarded — _ppGigProposal keeps new value', async () => {
// Failure input: two requests fire; second completes first; first (stale) must be dropped.
// Without the generation guard, the stale Drop response would overwrite the Standard proposal.
const ctx = makeBookCtx();
let resolveFirst, resolveSecond;
const first = new Promise(r => { resolveFirst = r; });
const second = new Promise(r => { resolveSecond = r; });
let callCount = 0;
ctx.fetch = () => {
callCount++;
return callCount === 1 ? first : second;
};
// Fire first request (drop), don't resolve yet
const p1 = vm.runInContext(`
window.__careerPassportTest.setTuningPref('drop');
window.__careerPassportTest.bookGig('rock');
`, ctx);
// Fire second request (standard) — increments gen
const p2 = vm.runInContext(`
window.__careerPassportTest.setTuningPref('standard');
window.__careerPassportTest.bookGig('rock');
`, ctx);
// Resolve SECOND first (standard wins)
resolveSecond({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'standard.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'standard' }) });
await p2;
// Now resolve stale FIRST (drop) — must be discarded
resolveFirst({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'drop.sloppak', tuning_name: 'Drop D' }], tuning_pref: 'drop' }) });
await p1;
// _ppGigProposal must reflect the second (standard) response, not the stale first.
// getProposal() exposes _ppGigProposal via the test seam.
const proposal = vm.runInContext('window.__careerPassportTest.getProposal()', ctx);
// If the generation guard is absent, stale drop overwrites standard → songs[0] is drop.sloppak
assert.ok(
proposal === null || proposal.songs[0].filename !== 'drop.sloppak',
'stale drop response must not overwrite the winning standard proposal'
);
});
test('F2: 500 error keeps user pref — only 404 reverts to any', async () => {
// Failure input: saved pref 'drop', server returns 500 → without fix, pref silently becomes 'any'
const ctx = makeBookCtx();
ctx.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
assert.equal(pref, 'drop', '500 error must not reset pref to any');
});
test('F2: 404 still reverts pref to any', async () => {
const ctx = makeBookCtx();
ctx.fetch = async () => ({ ok: false, status: 404, json: async () => ({ detail: 'No drop songs.' }) });
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
assert.equal(pref, 'any', '404 must revert pref to any');
});
test('F1b: stale 404 json completing after newer booking must not revert newer pref', async () => {
// Failure input: A 404 response is received (first gen check passes), then res.json()
// is awaited. A new booking fires while json() is pending (increments _ppBookGen).
// The error branch MUST re-check gen after json() and NOT revert pref to 'any'.
//
// We simulate the race by having json() bump _ppBookGen synchronously (equivalent to
// a new bookGig call arriving at exactly that moment) before returning a resolved value.
// After the await on json()'s resolved Promise, gen !== _ppBookGen → should bail.
const ctx = makeBookCtx();
ctx.fetch = async () => ({
ok: false,
status: 404,
json: () => {
// Simulate: a new booking fires while json() is in progress
vm.runInContext(
'window.__careerPassportTest.setBookGen(window.__careerPassportTest.getBookGen() + 1);',
ctx
);
vm.runInContext(`window.__careerPassportTest.setTuningPref('standard');`, ctx);
return Promise.resolve({ detail: 'No drop songs.' });
},
});
vm.runInContext(`window.__careerPassportTest.setTuningPref('drop');`, ctx);
await vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
const pref = vm.runInContext(`window.__careerPassportTest.getTuningPref()`, ctx);
assert.equal(pref, 'standard', 'stale 404 json must not revert newer pref to any');
});
test('F2: closeBook() invalidates in-flight request — overlay stays closed', async () => {
// Failure input: a booking request is in flight (pending fetch), then the user
// closes the poster via the REAL closeBook(). Without ++_ppBookGen in closeBook,
// the pending response resolves and repopulates _ppGigProposal.
//
// Mutation proof: delete `++_ppBookGen` from closeBook() → test goes RED
// (proposal is non-null, assert fails). Restore → GREEN.
const ctx = makeBookCtx();
let resolvePending;
ctx.fetch = () => new Promise(r => { resolvePending = r; });
// Fire a booking — stays pending
vm.runInContext(`window.__careerPassportTest.setTuningPref('any');`, ctx);
const pending = vm.runInContext(`window.__careerPassportTest.bookGig('rock');`, ctx);
// User closes the poster — call the REAL closeBook() via the test seam
vm.runInContext(`window.__careerPassportTest.closeBook();`, ctx);
// Now resolve the pending fetch with a valid payload
resolvePending({ ok: true, status: 200, json: async () => ({ songs: [{ filename: 'a.sloppak', tuning_name: 'E Standard' }], tuning_pref: 'any' }) });
await pending;
// Proposal must remain null — closeBook() incremented _ppBookGen so response was discarded
const proposal = vm.runInContext(`window.__careerPassportTest.getProposal()`, ctx);
assert.equal(proposal, null, 'closeBook() must invalidate in-flight request via ++_ppBookGen');
});
});
-346
View File
@@ -1,346 +0,0 @@
// h3d-carve-12: Regression coverage for T-section (arpeggio inference) extracted
// into plugins/highway_3d/src/arp.js.
//
// Test classes:
// - Source-level: module shape, DI param presence, screen.js wiring
// - Wiring-correspondence guard (naming-class invariant, PINNED_RENAMES = {})
// - Amendment 2 behavioral kill: resetChordShapeCache identity (gut reset → RED)
// - Amendment 3 behavioral kill: WeakMap re-keying guard (gut ref-keying → RED)
// - Per-export behavioral kills: mergeHandShapeSynthChords, mergeChordShape,
// chordShapeCoveredByStandaloneNotes, chordWireHighDensity, chordTemplateLabel
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 ARP_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'arp.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const arpSrc = fs.readFileSync(ARP_JS, 'utf8');
// ── Module shape ─────────────────────────────────────────────────────────────
test('arp.js exports createArp', () => {
assert.match(arpSrc, /export\s+function\s+createArp\s*\(/,
'arp.js must export createArp');
});
const EXPECTED_EXPORTS = [
'chordWireHighDensity', 'chordTemplateLabel', 'chordTemplateMarkedArpeggio',
'chordHandShapeArpeggioHint', 'mergeHandShapeSynthChords', 'mergeChordShape',
'resetChordShapeCache', 'inferArpeggioFromNotePattern',
'chordShapeCoveredByStandaloneNotes', 'hsStart', 'hsEnd', 'handShapeChartSpanSec',
'fillArpeggioGhostInferFlags', 'arpeggioChordIdForNoteWithInferCache',
'arpHsBoundsForNote', 'fillLaneRailHandShapeFlags', 'fillArpeggioRailShapeBoundsCaches',
'arpeggioLaneOuterRailLaneSlice', 'arpeggioLaneOuterRailAtChartTime',
'arpeggioLaneDividerFrameAccentMul', 'arpeggioLaneDividerXYScaleMatchFrameRim',
];
test('createArp return object declares all 21 exported symbols', () => {
for (const sym of EXPECTED_EXPORTS) {
assert.match(arpSrc, new RegExp('\\b' + sym + '\\b'),
`arp.js must mention '${sym}'`);
}
// The factory return is the last `return {` in the file (inner returns are earlier)
const lastReturnIdx = arpSrc.lastIndexOf('return {');
assert.ok(lastReturnIdx >= 0, 'createArp must have a return { ... } block');
const returnBlock = arpSrc.slice(lastReturnIdx);
const returnMatch = returnBlock.match(/return\s*\{([^}]+)\}/s);
assert.ok(returnMatch, 'factory return block must be parseable');
for (const sym of EXPECTED_EXPORTS) {
assert.ok(
returnMatch[1].includes(sym),
`return block must include '${sym}'`,
);
}
});
test('arp.js imports lowerBoundT directly from geometry.js (not via DI)', () => {
assert.match(arpSrc,
/import\s*\{\s*lowerBoundT\s*\}\s*from\s*'\.\/geometry\.js'/,
'lowerBoundT must be imported from geometry.js');
assert.doesNotMatch(arpSrc, /lowerBoundT\s*,/,
'lowerBoundT must not appear in the DI parameter list');
});
// ── DI surface checks ────────────────────────────────────────────────────────
test('NEXT_ON_STRING_T_EPS is in the DI parameter list (late-found in survey)', () => {
// Must appear as a destructured parameter, not just in usage
const paramBlock = arpSrc.match(/export\s+function\s+createArp\s*\(\s*\{([^}]+)\}/s);
assert.ok(paramBlock, 'must find createArp parameter block');
assert.ok(
paramBlock[1].includes('NEXT_ON_STRING_T_EPS'),
'NEXT_ON_STRING_T_EPS must be listed as a DI parameter',
);
});
test('getNStr getter is used in arpeggioLaneDividerXYScaleMatchFrameRim body (DI rewire)', () => {
assert.match(arpSrc, /getNStr\(\)/,
'getNStr() must be called somewhere in arp.js');
assert.match(arpSrc,
/arpeggioLaneDividerXYScaleMatchFrameRim[\s\S]{1,400}getNStr\(\)/,
'getNStr() must appear inside arpeggioLaneDividerXYScaleMatchFrameRim body');
});
// ── screen.js wiring ─────────────────────────────────────────────────────────
test('screen.js imports createArp from src/arp.js', () => {
assert.match(src,
/import\s*\{\s*createArp\s*\}\s*from\s*'\.\/src\/arp\.js'/,
'screen.js must import createArp');
});
test('screen.js T-section body is gone (truthyChartFlag function removed)', () => {
// truthyChartFlag lived only in the T-section and is private (not exported)
assert.doesNotMatch(src, /function\s+truthyChartFlag\s*\(/,
'truthyChartFlag must not remain as a function declaration in screen.js');
});
test('screen.js no longer contains _chordShapeCache = new WeakMap() direct assignment', () => {
// After cut, _chordShapeCache lives in arp.js; screen.js only calls resetChordShapeCache()
assert.doesNotMatch(src, /_chordShapeCache\s*=\s*new\s+WeakMap\(\)/,
'_chordShapeCache direct assignment must be gone from screen.js');
});
test('screen.js _resetStringDependentCaches calls resetChordShapeCache()', () => {
assert.match(src, /resetChordShapeCache\(\)/,
'screen.js must call resetChordShapeCache() in _resetStringDependentCaches');
});
test('screen.js callsite uses createArp factory destructure', () => {
assert.match(src,
/const\s*\{[\s\S]*chordWireHighDensity[\s\S]*\}\s*=\s*createArp\s*\(/,
'screen.js must destructure from createArp()');
});
// ── Wiring-correspondence guard ───────────────────────────────────────────────
// Every entry in createArp({…}) must satisfy its naming class.
// PINNED_RENAMES = {} (all entries follow standard convention).
// Kills param swaps like `getNStr: () => nStr` → `getNStr: () => mStr`.
test('createArp({...}) wiring has correct naming correspondence (no param swaps)', () => {
const PINNED_RENAMES = {};
const ANCHOR = '} = createArp({';
const callStart = src.indexOf(ANCHOR);
assert.ok(callStart >= 0, 'createArp 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, 'createArp 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 >= 19, `expected at least 19 entries, got ${entries.length}`);
const violations = [];
for (const entry of entries) {
if (!entry.includes(':')) continue; // shorthand (UPPERCASE or camelCase plain ref)
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-arrow`);
}
assert.deepEqual(violations, [], 'createArp wiring violations found');
});
// ── Behavioral fixture ────────────────────────────────────────────────────────
// Provides a default createArp instance with all 19 DI params stubbed.
async function makeArp(overrides = {}) {
const { createArp } = await import(pathToFileURL(ARP_JS).href + '?t=' + Date.now());
const defaults = {
validString: (s) => s >= 0 && s < 6,
filterValidNotes: (notes) => notes.filter(n => n.s >= 0 && n.s < 6),
sY: (s) => s * 10,
K: 5.0,
S_GAP: 10,
BEHIND: 100,
CHORD_FRAME_RIM_MIN: 0.01,
CHORD_FRAME_RIM_FRAC_H: 0.1,
ARP_FRAME_ONSET_PAD_S: 0.01,
ARP_FRAME_ONSET_CLUSTER_S: 0.03,
ARP_INFER_MIN_HAND_SHAPE_SPAN_S: 0.1,
ARP_INFER_STRUM_VS_ARP_SPREAD_MIN_S: 0.03,
ARP_INFER_MULTI_STRUM_HIT_SLACK: 0.02,
ARP_INFER_MULTI_STRUM_WIN_MIN_S: 0.1,
ARP_INFER_MIN_HITS_VS_SHAPE_CAP: 0.5,
ARP_HWY_RAIL_END_TAIL_S: 0.2,
ARP_HWY_RAIL_START_LEAD_S: 0.1,
NEXT_ON_STRING_T_EPS: 0.001,
getNStr: () => 6,
...overrides,
};
return createArp(defaults);
}
// ── Amendment 2: resetChordShapeCache identity-based kill ─────────────────────
// r1 = mergeChordShape(ch,...); r2 = same call → assert r1 === r2 (cache hit).
// resetChordShapeCache(); r3 = same call → assert r3 !== r1 (recomputed object).
// Gut the reset (no-op instead of new WeakMap) → r3 === r1 → RED.
test('Amendment 2: resetChordShapeCache invalidates the WeakMap — identity kill', async () => {
const arp = await makeArp();
const ch = { id: 0, t: 0 };
const notes = [{ s: 0, f: 3 }];
const templates = {};
const r1 = arp.mergeChordShape(ch, notes, templates);
const r2 = arp.mergeChordShape(ch, notes, templates);
assert.ok(r1 === r2, 'second call with same chord ref must return the cached Map (identity hit)');
arp.resetChordShapeCache();
const r3 = arp.mergeChordShape(ch, notes, templates);
assert.ok(r3 !== r1,
'call after resetChordShapeCache() must return a NEW Map object — gut the reset → r3 === r1 → RED');
// Sanity: content must still be the same even though the object changed
assert.deepEqual([...r3.entries()], [...r1.entries()], 'reset must not change computed shape data');
});
// ── Amendment 3: WeakMap re-keying guard ──────────────────────────────────────
// Simulates a song-switch: old chord objects dropped (new refs arrive).
// Same-refs: r1 === r2 (cache hit by object identity).
// New-refs: r3 !== r1 (WeakMap miss → recompute — not stale).
// Gut the ref-compare (switch to string-keyed Map by ch.id) → r3 === r1 → RED
// when ch2 has the same id as ch1.
test('Amendment 3: WeakMap re-keying — same-ref hit, new-ref recompute (song-switch guard)', async () => {
const arp = await makeArp();
const ch1 = { id: 7, t: 1.0 };
const ch2 = { id: 7, t: 1.0 }; // same data, different object reference
const notes = [];
const templates = { 7: { frets: [0, 1, 2, -1, -1, -1] } };
const r1 = arp.mergeChordShape(ch1, notes, templates);
const r2 = arp.mergeChordShape(ch1, notes, templates);
assert.ok(r1 === r2, 'same chord ref must get a cache hit (r1 === r2)');
const r3 = arp.mergeChordShape(ch2, notes, templates);
assert.ok(r3 !== r1,
'different chord ref (song-switch) must NOT get the stale cached entry — gut ref-keying → r3 === r1 → RED');
// Content must still be equal (same inputs)
assert.deepEqual([...r3.entries()], [...r1.entries()], 'recomputed shape must equal original');
});
// ── mergeChordShape behavioral kill ──────────────────────────────────────────
// Chord note override must win over template fret for the same string.
test('mergeChordShape: chord note overrides template fret on same string', async () => {
const arp = await makeArp();
const ch = { id: 5, t: 2.0 };
const notes = [{ s: 0, f: 7 }]; // override string 0 fret to 7
const templates = { 5: { frets: [3, 5, -1, -1, -1, -1] } }; // template says s0=3, s1=5
const shape = arp.mergeChordShape(ch, notes, templates);
assert.equal(shape.get(0), 7, 'chord note fret must override template fret on string 0');
assert.equal(shape.get(1), 5, 'template fret for string 1 must be preserved');
});
// ── mergeHandShapeSynthChords behavioral kill ─────────────────────────────────
// A hand shape with no coincident real chord must produce a synth chord entry.
test('mergeHandShapeSynthChords: synthesizes chord when hand-shape has no matching real chord', async () => {
const arp = await makeArp();
const templates = { 3: { frets: [0, 2, 2, -1, -1, -1] } };
const realChords = [];
const handShapes = [{ chord_id: 3, start_time: 1.0, end_time: 2.0 }];
const merged = arp.mergeHandShapeSynthChords(realChords, handShapes, templates);
assert.ok(merged.length === 1, 'one synth chord must be produced from the hand shape');
assert.ok(merged[0].h3dSynth === true, 'synth chord must be flagged h3dSynth');
assert.equal(merged[0].id, 3, 'synth chord must carry the hand-shape chord_id');
assert.ok(merged[0].notes.length > 0, 'synth chord must have notes from template');
});
test('mergeHandShapeSynthChords: real chord at same onset suppresses synth (no duplicate)', async () => {
const arp = await makeArp();
const templates = { 3: { frets: [0, 2, 2, -1, -1, -1] } };
const realChords = [{ t: 1.0, id: 3, notes: [] }];
const handShapes = [{ chord_id: 3, start_time: 1.0, end_time: 2.0 }];
const merged = arp.mergeHandShapeSynthChords(realChords, handShapes, templates);
assert.equal(merged.length, 1, 'real chord at same onset must suppress synth — no duplicate');
assert.ok(!merged[0].h3dSynth, 'the surviving entry must be the real chord, not synth');
});
// ── chordWireHighDensity / chordTemplateLabel simple kills ────────────────────
test('chordWireHighDensity returns true when chord.hd is truthy (boolean, 1, or "1")', async () => {
const arp = await makeArp();
assert.ok(arp.chordWireHighDensity({ hd: true }));
assert.ok(arp.chordWireHighDensity({ hd: 1 }));
assert.ok(arp.chordWireHighDensity({ hd: '1' }));
assert.ok(!arp.chordWireHighDensity({ hd: false }));
assert.ok(!arp.chordWireHighDensity({ hd: 0 }));
});
test('chordTemplateLabel returns displayName over name, empty string for null', async () => {
const arp = await makeArp();
assert.equal(arp.chordTemplateLabel({ displayName: 'Gm', name: 'Gm7' }), 'Gm');
assert.equal(arp.chordTemplateLabel({ name: 'Am' }), 'Am');
assert.equal(arp.chordTemplateLabel(null), '');
assert.equal(arp.chordTemplateLabel({}), '');
});
// ── arpeggioLaneDividerXYScaleMatchFrameRim DI rewire check ──────────────────
// getNStr() must be called to look up nStr; if it were hardcoded to a constant
// the test would break when getNStr returns a different value.
test('arpeggioLaneDividerXYScaleMatchFrameRim uses getNStr() for string count (DI rewire)', async () => {
const calls = [];
const arp = await makeArp({ getNStr: () => { calls.push(true); return 4; } });
// Call the function (it uses sY(0) and sY(getNStr()-1), both derived from nStr)
arp.arpeggioLaneDividerXYScaleMatchFrameRim(1.0);
assert.ok(calls.length > 0, 'getNStr() must be called inside arpeggioLaneDividerXYScaleMatchFrameRim');
});
+5 -9
View File
@@ -11,17 +11,13 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-12: chordShapeCoveredByStandaloneNotes moved to src/arp.js
const ARP_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'arp.js');
// h3d-carve-15: deferChordGems / noteStreamCoversArpShape moved to src/renderer.js
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
test('chordShapeCoveredByStandaloneNotes helper exists with the expected signature', () => { test('chordShapeCoveredByStandaloneNotes helper exists with the expected signature', () => {
const src = fs.readFileSync(ARP_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/function\s+chordShapeCoveredByStandaloneNotes\s*\(\s*ch\s*,\s*shape\s*,\s*notesArr\s*,\s*timeWin\s*\)/, /function\s+chordShapeCoveredByStandaloneNotes\s*\(\s*ch\s*,\s*shape\s*,\s*notesArr\s*,\s*timeWin\s*\)/,
'helper that scans the note stream for shape coverage must be in src/arp.js (moved from screen.js by h3d-carve-12)', 'helper that scans the note stream for shape coverage must remain on screen.js',
); );
}); });
@@ -29,7 +25,7 @@ test('deferChordGems gates both synth and explicit+covered branches on note-stre
// Either branch firing without coverage produces the empty-lavender-frame // Either branch firing without coverage produces the empty-lavender-frame
// regression PR #262 fixed. Pin both predicates so a refactor that drops // regression PR #262 fixed. Pin both predicates so a refactor that drops
// one gate fails the test. // one gate fails the test.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/const\s+deferChordGems\s*=\s*\(\s*ch\.h3dSynth\s*&&\s*noteStreamCoversArpShape\(\)\s*\)\s*\|\|\s*inferredArpPattern\s*\|\|\s*\(\s*hsHintFrame\.explicit\s*&&\s*hsHintFrame\.covered\s*&&\s*noteStreamCoversArpShape\(\)\s*\)/, /const\s+deferChordGems\s*=\s*\(\s*ch\.h3dSynth\s*&&\s*noteStreamCoversArpShape\(\)\s*\)\s*\|\|\s*inferredArpPattern\s*\|\|\s*\(\s*hsHintFrame\.explicit\s*&&\s*hsHintFrame\.covered\s*&&\s*noteStreamCoversArpShape\(\)\s*\)/,
@@ -41,14 +37,14 @@ test('noteStreamCoversArpShape is computed lazily (called, not eagerly bound)',
// Eager allocation regressed perf on dense charts (Copilot review on PR // Eager allocation regressed perf on dense charts (Copilot review on PR
// #262). The shape must be a callable so short-circuit evaluation skips // #262). The shape must be a callable so short-circuit evaluation skips
// the note-stream scan when neither gating branch needs it. // the note-stream scan when neither gating branch needs it.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/const\s+noteStreamCoversArpShape\s*=\s*(?:\(\s*\)\s*=>|function(?:\s+\w+)?\s*\(\s*\))/, /const\s+noteStreamCoversArpShape\s*=\s*(?:\(\s*\)\s*=>|function(?:\s+\w+)?\s*\(\s*\))/,
'noteStreamCoversArpShape must be an arrow/function so the scan is lazy', 'noteStreamCoversArpShape must be an arrow/function so the scan is lazy',
); );
assert.doesNotMatch( assert.doesNotMatch(
fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'), src,
/const\s+noteStreamCoversArpShape\s*=\s*chordShapeCoveredByStandaloneNotes\(/, /const\s+noteStreamCoversArpShape\s*=\s*chordShapeCoveredByStandaloneNotes\(/,
'noteStreamCoversArpShape must not eagerly invoke the coverage helper', 'noteStreamCoversArpShape must not eagerly invoke the coverage helper',
); );
-239
View File
@@ -1,239 +0,0 @@
// Class-killer tests for src/bc-panel.js — h3d-carve-4.
//
// Most tests are source-scan (grep for structural invariants that protect
// against specific mutations). Two tests eval _bcIsDesktop in isolation
// (a pure function that only reads window.*) using new Function so Node
// can run it without a browser. Screen.js wiring is verified by scanning
// the import declaration and checking that moved symbols are gone from the
// IIFE.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BC_PANEL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bc-panel.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() { if (!_src) _src = fs.readFileSync(BC_PANEL_JS, 'utf8'); return _src; }
let _screenSrc;
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
// ── Eval helper for _bcIsDesktop (pure; only reads window.*) ──────────────────
let _isDesktopFn;
function isDesktopFn() {
if (_isDesktopFn) return _isDesktopFn;
// Strip 'export' keywords; extract just _bcIsDesktop body for eval.
const stripped = src().replace(/^export\s+/gm, '');
// Wrap in a factory that returns _bcIsDesktop after evaluating the whole
// module (so inner references resolve). window is the only global needed.
const factory = new Function('window', stripped + '\nreturn _bcIsDesktop;');
_isDesktopFn = factory;
return _isDesktopFn;
}
// ── 1. _bcLoading reset on rejection ─────────────────────────────────────────
test('_bcLoading reset to null when lib-load promise rejects', () => {
// Mutation: remove `.catch(() => { _bcLoading = null; })` from _bcLoadLib.
// Without it, a rejected load-promise is cached forever; every subsequent
// mount returns the rejected promise immediately → Butterchurn permanently
// disabled for the session with no retry.
assert.match(src(), /_bcLoading\s*=\s*null/,
'_bcLoadLib must reset _bcLoading to null in a .catch handler so failed loads retry');
// Verify the reset appears in a .catch context (not just an early-return path).
assert.match(src(), /\.catch\s*\([\s\S]{1,60}_bcLoading\s*=\s*null/,
'_bcLoading = null must appear inside a .catch callback');
});
// ── 2. window.h3dBcApplySettings at module scope ──────────────────────────────
test('window.h3dBcApplySettings is assigned at module scope (not inside a function)', () => {
// Mutation: move assignment inside _bcCreateController → it isn't available
// until first mount; settings.html's `?.` call silently no-ops → settings
// changes (opacity, enabled, cycle mode) never apply until the user visits
// the player for the first time.
//
// Structural check: the assignment must appear BEFORE the first `function`
// or `export function` declaration in bc-panel.js (i.e. at module scope).
const s = src();
// Line-anchored regex: `^window.` matches only an unindented assignment.
// A comment mention or an indented assignment (inside a function body) both
// fail to match and return -1 — so this single check is sufficient.
const assignIdx = s.search(/^window\.h3dBcApplySettings\s*=/m);
assert.ok(assignIdx >= 0,
'window.h3dBcApplySettings must be assigned at line-start (module scope) in bc-panel.js — ' +
'an indented assignment (inside a function) would not match /^window\\./m');
});
// ── 3. _bcIsDesktop guards all three required conditions ──────────────────────
test('_bcIsDesktop checks isDesktop, .audio, and typeof getRawAudioFrame', () => {
// Mutation: remove any one guard → non-desktop host (Docker/web app) enters
// the desktop guitar-feed path → audioProvider is wrong; pcmLoop errors
// every 16 ms trying to call an undefined getRawAudioFrame.
const s = src();
assert.match(s, /d\.isDesktop/,
'_bcIsDesktop must guard on d.isDesktop');
assert.match(s, /d\.audio/,
'_bcIsDesktop must guard on d.audio');
assert.match(s, /typeof\s+d\.audio\.getRawAudioFrame\s*===\s*'function'/,
"_bcIsDesktop must guard typeof d.audio.getRawAudioFrame === 'function'");
});
// ── 4. _bcIsDesktop eval: returns false when window has no desktop bridge ─────
test('_bcIsDesktop returns false when window.feedBackDesktop is absent', () => {
// Mutation: remove the `d && ...` guard → accessing .isDesktop on undefined
// throws in the browser; the whole highway init crashes.
const fn = isDesktopFn()({ feedBackDesktop: undefined, slopsmithDesktop: undefined });
assert.strictEqual(fn(), false,
'_bcIsDesktop must return false when neither feedBackDesktop nor slopsmithDesktop is set');
});
// ── 5. _bcIsDesktop eval: returns false when isDesktop is missing from bridge ─
test('_bcIsDesktop returns false when bridge has audio but no isDesktop flag', () => {
// Mutation: rely on truthy bridge presence alone (drop isDesktop check) → any host
// that exposes feedBackDesktop for non-guitar purposes (e.g. file manager) would
// be misidentified as the guitar-input desktop host.
const fn = isDesktopFn()({
feedBackDesktop: { audio: { getRawAudioFrame: () => new Float32Array(512) } },
slopsmithDesktop: undefined,
});
assert.strictEqual(fn(), false,
'_bcIsDesktop must return false when feedBackDesktop lacks the isDesktop flag');
});
// ── 6. destroy() removes controller from _bcControllers ──────────────────────
test('destroy() calls _bcControllers.delete(ctrl)', () => {
// Mutation: remove delete call → dead controller stays in _bcControllers;
// _bcApplyAll iterates the Set and calls applySettings() on the dead controller;
// null canvas/scrim refs throw; multiple repeated mounts eventually saturate Set.
assert.match(src(), /_bcControllers\.delete\s*\(\s*ctrl\s*\)/,
'destroy() must call _bcControllers.delete(ctrl) to remove the dead controller');
});
// ── 7. _bcReleaseCanvasGL called in destroy() ─────────────────────────────────
test('_bcReleaseCanvasGL is called inside destroy()', () => {
// Mutation: remove the release call from destroy() → WebGL context not freed on
// dismount; browsers allow ~16 concurrent contexts; repeated mount/toggles
// exhaust the cap; subsequent mounts get null from getContext('webgl') →
// Butterchurn init silently fails.
//
// The call appears in two places: destroy() method and the .catch error handler.
// Both are required. This test checks that destroy() includes it.
const s = src();
// `destroy() {` (with space+brace) anchors the actual method definition,
// not comment references like "so destroy() closes only ...".
const destroyIdx = s.indexOf('destroy() {');
assert.ok(destroyIdx >= 0, 'destroy() method must exist in the returned controller object');
// Find the release call after the destroy label (within 1000 chars).
const destroyBlock = s.slice(destroyIdx, destroyIdx + 1000);
assert.match(destroyBlock, /_bcReleaseCanvasGL/,
'_bcReleaseCanvasGL must be called inside destroy() to free the WebGL context');
});
// ── 8. _bcReleaseCanvasGL called in async-init failure handler ────────────────
test('_bcReleaseCanvasGL is called in the _bcLoadLib().catch handler', () => {
// Mutation: remove from .catch → half-initialised failure (lib load, WebGL
// context creation) leaves a bound WebGL context on the abandoned canvas;
// the context is never freed; same cap exhaustion as above.
const s = src();
// The error handler's .catch takes a named error param (e) and logs via
// console.error — that's how we distinguish it from the narrow no-op catches.
// Look for `_bcReleaseCanvasGL` inside a `.catch((e) => {` block.
assert.match(s, /\.catch\s*\(\s*\(e\)[\s\S]{1,600}_bcReleaseCanvasGL/,
'_bcReleaseCanvasGL must appear in the error-handler .catch((e) => {}) block');
});
// ── 9. _bcLoadSettings merges saved state with BC_DEFAULTS ───────────────────
test('_bcLoadSettings uses Object.assign with BC_DEFAULTS as base', () => {
// Mutation: return raw parsed JSON without merging → missing keys from
// localStorage (fresh install, partial save) become undefined;
// s.enabled → undefined → bg disabled on first launch.
assert.match(src(), /Object\.assign\s*\(\s*\{\s*\}\s*,\s*BC_DEFAULTS/,
'_bcLoadSettings must merge with BC_DEFAULTS so missing keys get defaults');
});
// ── 10. screen.js imports both exports from src/bc-panel.js ──────────────────
test('screen.js imports _bcCreateController and _bcIsDesktop from src/bc-panel.js', () => {
// Mutation: remove import → H-section call to _bcCreateController throws
// ReferenceError at the first butterchurn mount; the 3D highway becomes
// permanently broken when butterchurn bg is selected.
const s = screenSrc();
assert.match(s,
/import\s+\{[^}]*_bcCreateController[^}]*\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/,
'screen.js must import _bcCreateController from ./src/bc-panel.js');
assert.match(s,
/import\s+\{[^}]*_bcIsDesktop[^}]*\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/,
'screen.js must import _bcIsDesktop from ./src/bc-panel.js');
});
// ── 12. screen.js has no bare caller references to private bc-panel.js symbols ─
test('every bc-panel.js export referenced in screen.js IIFE is in the import statement', () => {
// Mutation: remove _bcLoadSettings (or any other export) from the screen.js import
// → symbol is exported by bc-panel.js, referenced in the IIFE, but not bound via
// import → ReferenceError at runtime. This is the class Creed HIGH found on
// cut 4 (screen.js:15396-15402: _bcLoadSettings + _bcFfIdx called but not imported).
//
// Method (generalised):
// exported = all _bc* symbols with `export` keyword in bc-panel.js
// imported = all symbols in screen.js's `from './src/bc-panel.js'` import clause
// leaked = exported symbols that appear as bare refs in screen.js IIFE
// but are NOT in imported
// Adding a new export and a new caller without updating the import → leaked is
// non-empty → test RED.
const bcSrc = src();
const scrSrc = screenSrc();
// All exported _bc* symbols from bc-panel.js.
const exported = new Set(
[...bcSrc.matchAll(/^export\s+(?:const|let|var|function)\s+(_bc\w+)/mg)].map(m => m[1])
);
// Symbols actually imported from bc-panel.js in screen.js.
const importMatch = scrSrc.match(/import\s+\{([^}]+)\}\s+from\s+['"]\.\/src\/bc-panel\.js['"]/);
const imported = new Set(
importMatch ? importMatch[1].split(',').map(s => s.trim()).filter(Boolean) : []
);
// IIFE body: strip import lines and line comments to avoid false positives.
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
const noComments = noImports.replace(/\/\/[^\n]*/g, '');
// Exported symbols referenced in the IIFE body but absent from the import list.
const leaked = [...exported].filter(
sym => new RegExp('\\b' + sym + '\\b').test(noComments) && !imported.has(sym)
);
assert.deepStrictEqual(leaked, [],
'screen.js references bc-panel.js exports that are not in its import clause: ' +
leaked.join(', ') +
' — add them to the import { … } from \'./src/bc-panel.js\' line in screen.js');
});
// ── 11. screen.js IIFE no longer defines moved B-section symbols ──────────────
test('screen.js IIFE does not redeclare _bcCreateController or _bcLoadLib', () => {
// Mutation: re-add function _bcCreateController() to the IIFE → double definition;
// the IIFE-scope function shadows the imported one inside the factory; bc-panel.js
// private state is split: the IIFE copy has its own _bcControllers, _bcSettings, etc.
// The panel never shows, controller objects leak, applySettings no-ops.
const s = screenSrc();
// Strip import lines so we only scan the IIFE body.
const iife = s.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_bcCreateController\s*\(/,
'IIFE must not redeclare _bcCreateController');
assert.doesNotMatch(iife, /function\s+_bcLoadLib\s*\(/,
'IIFE must not redeclare _bcLoadLib');
});
-367
View File
@@ -1,367 +0,0 @@
// Class-killer tests for src/bg-control.js — h3d-carve-5.
//
// bg-control.js uses a factory export (createBgControl({DI})) because its
// dependencies are IIFE-scope values that cannot be ES-module imports.
// screen.js destructures { _pcAcquire, _pcRelease } from the factory result.
//
// Test strategy:
// - Source-scan tests check structural invariants (critical paths, DI wiring,
// accessor call site, tombstone).
// - Screen.js wiring tests check the import clause and destructure form.
// - Generic stranded-caller test (adapted from bc-panel.js test 12) checks
// that every _pc* symbol in the bg-control.js factory return is also in
// screen.js's createBgControl destructure — a bare _pcFoo reference in the
// IIFE that isn't in the destructure is the same stranded-caller bug class.
// - Construction-order test: createBgControl call must appear AFTER all DI
// definitions in screen.js and BEFORE createFactory.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const BG_CONTROL_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'bg-control.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _src;
function src() { if (!_src) _src = fs.readFileSync(BG_CONTROL_JS, 'utf8'); return _src; }
let _screenSrc;
function screenSrc() { if (!_screenSrc) _screenSrc = fs.readFileSync(SCREEN_JS, 'utf8'); return _screenSrc; }
// ── 1. createBgControl is exported (not private) ──────────────────────────────
test('createBgControl is exported from bg-control.js', () => {
// Mutation: remove `export` → screen.js import throws SyntaxError /
// "does not provide an export" at module-graph load time → highway never
// initialises; all 3D-Hwy users see a blank canvas.
assert.match(src(), /^export\s+function\s+createBgControl\s*\(/m,
'createBgControl must be a line-start export function declaration');
});
// ── 2. DI params declared (all five) ─────────────────────────────────────────
test('createBgControl destructures all five DI params', () => {
// Mutation: remove one DI param → that function is `undefined` inside the
// factory → every call to e.g. _bgReadGlobal throws TypeError: not a function.
const s = src();
const sig = s.match(/export\s+function\s+createBgControl\s*\(\s*\{([^}]+)\}/);
assert.ok(sig, 'createBgControl signature must use destructuring params');
const params = sig[1];
assert.match(params, /BG_STYLE_IDS/, 'DI must include BG_STYLE_IDS');
assert.match(params, /_bgReadGlobal/, 'DI must include _bgReadGlobal');
assert.match(params, /_bgSubscribe/, 'DI must include _bgSubscribe');
assert.match(params, /_bgUnsubscribe/, 'DI must include _bgUnsubscribe');
assert.match(params, /getVenueSceneOverride/, 'DI must include getVenueSceneOverride');
});
// ── 3. getVenueSceneOverride() called as function (not captured at construction) ──
test('_pcSync calls getVenueSceneOverride() not _venueSceneOverride directly', () => {
// Mutation: revert beyond-subst 2 to `!!_venueSceneOverride` → factory
// captures the initial `false` at construction time; the accessor is never
// called; Venue-active state is always `false` → UI never goes inert under
// Venue; user can "pick" a background while Venue scene is active but the
// pick goes nowhere because Venue owns the mount.
const s = src();
// Must call the accessor (with parens).
assert.match(s, /getVenueSceneOverride\(\)/,
'_pcSync must call getVenueSceneOverride() rather than capturing the var at construction');
// Must NOT contain the raw closure variable name in executable code (bare, without call
// parens). Strip line comments first so the comment-doc in the file header doesn't fire.
const noLineComments = s.replace(/\/\/[^\n]*/g, '');
assert.doesNotMatch(noLineComments, /\b_venueSceneOverride\b/,
'bg-control.js must not reference bare _venueSceneOverride in code — use getVenueSceneOverride()');
});
// ── 4. _bgSubscribe called inside _pcMount ────────────────────────────────────
test('_bgSubscribe is called inside _pcMount to register the settings listener', () => {
// Mutation: remove _bgSubscribe call → control mounts but never receives
// settings-bus events; a style change from Settings page never syncs back
// to the in-player picker; the two UIs drift permanently.
const s = src();
const mountIdx = s.indexOf('function _pcMount()');
assert.ok(mountIdx >= 0, '_pcMount must be defined in bg-control.js');
const mountBlock = s.slice(mountIdx, mountIdx + 6000); // _bgSubscribe ~5200 chars in
assert.match(mountBlock, /_bgSubscribe\s*\(/,
'_bgSubscribe must be called inside _pcMount to register the listener');
});
// ── 5. _bgUnsubscribe called inside _pcTeardownDom ───────────────────────────
test('_bgUnsubscribe is called inside _pcTeardownDom to deregister the listener', () => {
// Mutation: remove _bgUnsubscribe call → listener closure outlives the control;
// after release the stale closure still calls _pcSync on every settings change;
// null refs (_pcSel etc.) throw on first setting write post-teardown.
const s = src();
const teardownIdx = s.indexOf('function _pcTeardownDom()');
assert.ok(teardownIdx >= 0, '_pcTeardownDom must be defined in bg-control.js');
const teardownBlock = s.slice(teardownIdx, teardownIdx + 500);
assert.match(teardownBlock, /_bgUnsubscribe\s*\(/,
'_bgUnsubscribe must be called inside _pcTeardownDom to remove the listener');
});
// ── 6. _pcRelease calls _pcTeardownDom ───────────────────────────────────────
test('_pcRelease calls _pcTeardownDom when refcount reaches zero', () => {
// Mutation: remove _pcTeardownDom() call from _pcRelease → DOM node is
// never removed; the settings listener stays alive; under splitscreen each
// renderer destroys independently but the control never disappears → orphaned
// picker remains visible and partially interactive after 3D-Hwy is deselected.
const s = src();
const releaseIdx = s.indexOf('function _pcRelease()');
assert.ok(releaseIdx >= 0, '_pcRelease must be defined in bg-control.js');
const releaseBlock = s.slice(releaseIdx, releaseIdx + 1200); // _pcTeardownDom ~1040 chars in
assert.match(releaseBlock, /_pcTeardownDom\s*\(\s*\)/,
'_pcRelease must call _pcTeardownDom() when refcount reaches zero');
});
// ── 7. _pcAcquire and _pcRelease returned from factory ───────────────────────
test('createBgControl returns { _pcAcquire, _pcRelease }', () => {
// Mutation: remove either from return → screen.js destructure gets undefined;
// first call to _pcAcquire / _pcRelease from init()/destroy() throws
// TypeError: not a function → highway init crashes on every song load.
const s = src();
const returnMatch = s.match(/return\s*\{([^}]+)\}/);
assert.ok(returnMatch, 'createBgControl must have a return { ... } statement');
const returned = returnMatch[1];
assert.match(returned, /_pcAcquire/, 'createBgControl must return _pcAcquire');
assert.match(returned, /_pcRelease/, 'createBgControl must return _pcRelease');
});
// ── 8. screen.js imports createBgControl from src/bg-control.js ──────────────
test('screen.js imports createBgControl from src/bg-control.js', () => {
// Mutation: remove import → createBgControl is undefined in the IIFE;
// the destructure const { _pcAcquire, _pcRelease } = createBgControl({...})
// throws TypeError at module eval time → plugin never loads.
assert.match(screenSrc(),
/import\s+\{[^}]*createBgControl[^}]*\}\s+from\s+['"]\.\/src\/bg-control\.js['"]/,
'screen.js must import createBgControl from ./src/bg-control.js');
});
// ── 9. screen.js calls createBgControl with all five DI args ─────────────────
test('screen.js passes all five DI arguments to createBgControl', () => {
// Mutation: omit one DI arg → the corresponding param is `undefined` inside
// the factory closure; first call to it (on mount, on settings change) throws.
const s = screenSrc();
const callMatch = s.match(/createBgControl\s*\(\s*\{([^}]+)\}/);
assert.ok(callMatch, 'screen.js must call createBgControl({...})');
const args = callMatch[1];
assert.match(args, /BG_STYLE_IDS/, 'createBgControl call must pass BG_STYLE_IDS');
assert.match(args, /_bgReadGlobal/, 'createBgControl call must pass _bgReadGlobal');
assert.match(args, /_bgSubscribe/, 'createBgControl call must pass _bgSubscribe');
assert.match(args, /_bgUnsubscribe/, 'createBgControl call must pass _bgUnsubscribe');
assert.match(args, /getVenueSceneOverride/, 'createBgControl call must pass getVenueSceneOverride');
});
// ── 10. screen.js IIFE does not redefine _pcAcquire or _pcRelease ────────────
test('screen.js IIFE does not redeclare _pcAcquire or _pcRelease', () => {
// Mutation: re-add `function _pcAcquire()` to the IIFE → IIFE-scope function
// shadows the destructured import; the factory's _pcRelease holds a stale
// closure over the old _pcRefs; refcount goes out of sync; the control
// never unmounts.
const s = screenSrc();
const iife = s.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_pcAcquire\s*\(/,
'IIFE must not redeclare _pcAcquire');
assert.doesNotMatch(iife, /function\s+_pcRelease\s*\(/,
'IIFE must not redeclare _pcRelease');
});
// ── 11. Construction order: createBgControl called before createFactory ───────
test('createBgControl call appears before createFactory in screen.js', () => {
// Mutation: move createBgControl call inside createFactory → each renderer
// instance gets its own independent control (refcount broken across instances);
// or if moved after createFactory but before register, correct for
// single-instance but still wrong order risk. This test ensures the call
// stays at module scope BEFORE the factory.
const s = screenSrc();
const bgCallIdx = s.indexOf('createBgControl(');
const factoryIdx = s.indexOf('function createFactory()');
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
assert.ok(factoryIdx >= 0, 'createFactory must exist in screen.js');
assert.ok(bgCallIdx < factoryIdx,
'createBgControl must be called before createFactory in screen.js');
});
// ── 12. Construction order: DI values defined before createBgControl call ─────
test('all DI values are defined before the createBgControl call in screen.js', () => {
// Mutation: move createBgControl call before BG_STYLE_IDS / _bgReadGlobal /
// _bgSubscribe / _bgUnsubscribe / getVenueSceneOverride binding →
// undefined passed as DI params; factory closure captures undefined → TypeError.
const s = screenSrc();
const bgCallIdx = s.indexOf('createBgControl(');
assert.ok(bgCallIdx >= 0, 'createBgControl call must exist in screen.js');
const bgStyleIdsIdx = s.indexOf('BG_STYLE_IDS =');
const bgReadIdx = s.indexOf('function _bgReadGlobal(');
const bgSubIdx = s.indexOf('function _bgSubscribe(');
const venueIdx = s.indexOf('let _venueSceneOverride');
assert.ok(bgStyleIdsIdx < bgCallIdx, 'BG_STYLE_IDS must be defined before createBgControl call');
assert.ok(bgReadIdx < bgCallIdx, '_bgReadGlobal must be defined before createBgControl call');
assert.ok(bgSubIdx < bgCallIdx, '_bgSubscribe must be defined before createBgControl call');
assert.ok(venueIdx < bgCallIdx, '_venueSceneOverride must be defined before createBgControl call');
});
// ── 13. Stranded-caller: every returned symbol must be in screen.js destructure ─
test('every _pc* symbol returned by createBgControl is in the screen.js destructure', () => {
// For a factory module the stranded-caller class is: a symbol in the factory's
// `return { ... }` that is NOT in the screen.js `const { ... } = createBgControl(...)`
// destructure — the factory vends it but screen.js never binds it, so any IIFE
// code that tries to call it hits ReferenceError.
//
// Mutation: add `_pcNewFn` to bg-control.js return {...} but not to screen.js
// destructure → leaked = ['_pcNewFn'] → RED.
const bgSrc = src();
const scrSrc = screenSrc();
// Symbols in the return { ... } of createBgControl.
const returnMatch = bgSrc.match(/return\s*\{([^}]+)\}/);
assert.ok(returnMatch, 'bg-control.js must have a return { ... } statement');
const returned = new Set(
returnMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
// Symbols in the screen.js destructure.
const destructureMatch = scrSrc.match(/const\s*\{\s*([^}]+)\}\s*=\s*createBgControl\s*\(/);
assert.ok(destructureMatch, 'screen.js must destructure the createBgControl result');
const destructured = new Set(
destructureMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
// Every returned symbol must be bound by the destructure (return ⊆ destructure).
const leaked = [...returned].filter(sym => !destructured.has(sym));
assert.deepStrictEqual(leaked, [],
'createBgControl returns symbols not bound by screen.js destructure: ' +
leaked.join(', '));
});
// ── 14. Stale-private guard: no private bg-control.js symbol bare in screen.js ─
test('no private bg-control.js symbol appears bare in screen.js IIFE body', () => {
// The cut-4 stale-private-reference class: a function or variable from a moved
// module that still appears as a bare name in screen.js (not via the destructure,
// not inside an import line, not inside a comment). If bg-control.js is re-merged
// or a caller copy-pastes `_pcSync(...)` into screen.js, this test goes RED.
//
// Mutation: add `_pcSync()` somewhere in screen.js IIFE body (outside the
// createBgControl destructure line) → stale = ['_pcSync'] → RED.
const bgSrc = src();
const scrSrc = screenSrc();
// All _pc* identifiers in bg-control.js. Full scan (not just declaration
// syntax) so multi-var lets like `let _pcEl, _pcSel, _pcReactive, ...`
// on a single line are all captured — the previous declaration-only regex
// only matched the first id per statement.
const defined = new Set(
[...bgSrc.matchAll(/\b(_pc\w+)\b/g)].map(m => m[1])
);
// Public symbols (in the destructure) are legitimately referenced in screen.js.
const destructureMatch = scrSrc.match(/const\s*\{\s*([^}]+)\}\s*=\s*createBgControl\s*\(/);
assert.ok(destructureMatch, 'screen.js must destructure the createBgControl result');
const destructured = new Set(
destructureMatch[1].split(',').map(s => s.trim().replace(/^(\w+)\s*:.*$/, '$1')).filter(Boolean)
);
const privateSymbols = [...defined].filter(sym => !destructured.has(sym));
// Strip imports, block comments (tombstone), line comments, and the destructure
// statement itself so the bound symbols don't fire false positives.
const noImports = scrSrc.replace(/^import\s+.*\n/gm, '');
const noBlockComments = noImports.replace(/\/\*[\s\S]*?\*\//g, '');
const noLineComments = noBlockComments.replace(/\/\/[^\n]*/g, '');
const noDestructure = noLineComments.replace(
/const\s*\{[^}]+\}\s*=\s*createBgControl\s*\([^)]*\)\s*;/, '',
);
const stale = privateSymbols.filter(
sym => new RegExp('\\b' + sym + '\\b').test(noDestructure),
);
assert.deepStrictEqual(stale, [],
'screen.js contains bare references to private bg-control.js symbols: ' +
stale.join(', '));
});
// ── 15. Literal-table pin: _PC_C colors, _PC_PILL CSS, _PC_LABELS, _PC_USES ──
test('bg-control.js literal tables match known-good values', () => {
// Mutation: any single literal change in _PC_C, _PC_PILL, _PC_LABELS, or
// _PC_USES (e.g. idle '#181830' → '#181831', or intensity: true → false for
// a style that should react to audio) → assertion fails → RED.
// These are inline-styled player-chrome pills whose correctness is invisible
// to runtime tests; without a pin a visual regression ships silently.
const s = src();
// ── _PC_C: inline color tokens from tailwind.config.js ────────────────────
const PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500
onBg: 'rgba(20,83,45,0.5)',// bg-green-900/50
onText: '#86efac', // text-green-300
};
for (const [key, val] of Object.entries(PC_C)) {
assert.ok(s.includes(`${key}: '${val}'`),
`_PC_C.${key} must equal '${val}'`);
}
// ── _PC_PILL: pill-button CSS ──────────────────────────────────────────────
for (const frag of [
'padding:.375rem .75rem',
'border-radius:.5rem',
'font-size:.75rem',
'cursor:pointer',
]) {
assert.ok(s.includes(frag), `_PC_PILL must contain "${frag}"`);
}
// ── _PC_LABELS: display names for each background style ───────────────────
const LABELS = {
off: 'Off',
particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)',
lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image',
video: 'Custom video',
};
for (const [key, label] of Object.entries(LABELS)) {
assert.ok(s.includes(`${key}: '${label}'`) || s.includes(`${key}: "${label}"`),
`_PC_LABELS.${key} must equal '${label}'`);
}
// ── _PC_USES: which controls each style enables ────────────────────────────
// [style, intensity, reactive]
const USES = [
['off', false, false],
['particles', true, true],
['silhouettes', true, true],
['lights', true, true],
['geometric', true, true],
['image', true, false],
['video', false, false],
['butterchurn', false, false],
['venue', false, false],
];
const usesBlock = s.match(/const _PC_USES\s*=\s*\{([\s\S]*?)\n\s*\};/);
assert.ok(usesBlock, '_PC_USES table must be present in bg-control.js');
const usesBody = usesBlock[1];
for (const [style, intensity, reactive] of USES) {
const entry = usesBody.match(new RegExp(style + '\\s*:\\s*\\{([^}]+)\\}'));
assert.ok(entry, `_PC_USES must contain '${style}' entry`);
const block = entry[1];
assert.match(block, new RegExp('intensity:\\s*' + intensity),
`_PC_USES.${style}.intensity must be ${intensity}`);
assert.match(block, new RegExp('reactive:\\s*' + reactive),
`_PC_USES.${style}.reactive must be ${reactive}`);
}
});
+16 -162
View File
@@ -12,15 +12,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-15: U-section (bootstrap region C) moved to renderer.js; scan both.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// Since h3d-carve-1b, hwyFirstRelevantFrettedTime lives in geometry.js.
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
const geoSrc = fs.readFileSync(GEOMETRY_JS, 'utf8');
// h3d-carve-9: camUpdate body moved to camera.js — extractFn retargets there.
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
function extractFn(source, name) { function extractFn(source, name) {
const start = source.indexOf('function ' + name); const start = source.indexOf('function ' + name);
@@ -44,7 +36,7 @@ function sourceBetween(startText, endText) {
const hwyFirstRelevantFrettedTime = new Function( const hwyFirstRelevantFrettedTime = new Function(
'"use strict";' '"use strict";'
+ extractFn(geoSrc, 'hwyFirstRelevantFrettedTime') + extractFn(src, 'hwyFirstRelevantFrettedTime')
+ '\nreturn hwyFirstRelevantFrettedTime;', + '\nreturn hwyFirstRelevantFrettedTime;',
)(); )();
@@ -119,11 +111,11 @@ test('recent onsets inside the behind-window bootstrap at now', () => {
test('bootstrap runs once when complete chart arrays arrive', () => { test('bootstrap runs once when complete chart arrays arrive', () => {
const bootstrap = sourceBetween( const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)', '// ── Camera bootstrap (first chart data)',
' pbBeg(4);', ' pbBeg(4);',
); );
assert.match( assert.match(
bootstrap, bootstrap,
/if\s*\(\s*!getCamSnapped\s*\(\s*\)\s*&&\s*!getCamPreScanned\s*\(\s*\)\s*&&\s*notes\s*&&\s*chords\s*\)/, /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', 'chart bootstrap must be gated to one pass after both arrays arrive',
); );
assert.match( assert.match(
@@ -133,7 +125,7 @@ test('bootstrap runs once when complete chart arrays arrive', () => {
); );
assert.match( assert.match(
bootstrap, bootstrap,
/firstFrettedTime\s*===\s*null[\s\S]*?setCamSnapped\s*\(\s*true\s*\)/, /firstFrettedTime\s*===\s*null[\s\S]*?_camSnapped\s*=\s*true/,
'all-open/empty charts without lookahead bounds must permanently disable bootstrap work', 'all-open/empty charts without lookahead bounds must permanently disable bootstrap work',
); );
}); });
@@ -141,7 +133,7 @@ test('bootstrap runs once when complete chart arrays arrive', () => {
test('steady and lookahead modes initialize immediately from future chart data', () => { test('steady and lookahead modes initialize immediately from future chart data', () => {
const bootstrap = sourceBetween( const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)', '// ── Camera bootstrap (first chart data)',
' pbBeg(4);', ' pbBeg(4);',
); );
assert.match( assert.match(
bootstrap, bootstrap,
@@ -165,7 +157,7 @@ test('steady and lookahead modes initialize immediately from future chart data',
); );
assert.match( assert.match(
bootstrap, bootstrap,
/setCurX\s*\(\s*getTgtX\s*\(\)\s*\)\s*;[\s\S]*?setCurDist\s*\(\s*getTgtDist\s*\(\)\s*\)\s*;/, /curX\s*=\s*tgtX\s*;[\s\S]*?curDist\s*=\s*tgtDist\s*;/,
'the initial base position must be applied before the note draw loop', 'the initial base position must be applied before the note draw loop',
); );
}); });
@@ -182,38 +174,31 @@ test('silent-intro hold hands off only when live framing is ready', () => {
); );
assert.match( assert.match(
target, target,
/if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*getPrevLockActive\s*\(\s*\)/, /if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*prevLockActive/,
'the bootstrap target must remain untouched while the live window is empty', 'the bootstrap target must remain untouched while the live window is empty',
); );
assert.match( assert.match(
target, target,
// h3d-carve-15: bare assignments → setter calls in renderer.js /_camBootstrapMode\s*!==\s*cameraMode[\s\S]*?_camBootstrapHolding\s*=\s*false/,
/getCamBootstrapMode\(\)\s*!==\s*cameraMode[\s\S]*?setCamBootstrapHolding\s*\(\s*false\s*\)/,
'a live camera-mode change must safely release the old-mode hold', 'a live camera-mode change must safely release the old-mode hold',
); );
}); });
test('song changes and teardown reset every bootstrap state field', () => { test('song changes and teardown reset every bootstrap state field', () => {
// h3d-carve-15: song-change path uses setter calls (renderer.js); const resetAssignments = src.match(
// teardown/init path uses bare assignments (screen.js). Both must exist. /_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,
const setterResets = src.match(
/setCamSnapped\s*\(\s*false\s*\)\s*;\s*\r?\n\s*setCamPreScanned\s*\(\s*false\s*\)/g,
) || []; ) || [];
const bareResets = src.match(
/_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false/g,
) || [];
const totalResets = setterResets.length + bareResets.length;
assert.equal( assert.equal(
totalResets, resetAssignments.length,
2, 2,
`song-change and teardown paths must both reset bootstrap state (setter=${setterResets.length}, bare=${bareResets.length})`, 'song-change and teardown paths must both reset bootstrap state',
); );
}); });
test('Camera Director still layers after the bootstrapped auto-framing base', () => { test('Camera Director still layers after the bootstrapped auto-framing base', () => {
const bootstrap = sourceBetween( const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)', '// ── Camera bootstrap (first chart data)',
' pbBeg(4);', ' pbBeg(4);',
); );
assert.doesNotMatch( assert.doesNotMatch(
bootstrap, bootstrap,
@@ -221,10 +206,8 @@ test('Camera Director still layers after the bootstrapped auto-framing base', ()
'bootstrap must only initialize base framing, never mutate Camera Director state', 'bootstrap must only initialize base framing, never mutate Camera Director state',
); );
// h3d-carve-9: extractFn must target cameraSrc — src holds only the tombstone. const camUpdate = extractFn(src, 'camUpdate');
// tgtX is DI-rewired to getTgtX() direct call in camera.js. const baseIndex = camUpdate.indexOf('curX += (tgtX - curX) * lerp');
const camUpdate = extractFn(cameraSrc, 'camUpdate');
const baseIndex = camUpdate.indexOf('curX += (getTgtX() - curX) * lerp');
const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)'); const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)');
const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)'); const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)');
assert.ok( assert.ok(
@@ -232,132 +215,3 @@ test('Camera Director still layers after the bootstrapped auto-framing base', ()
'Camera Director transforms must remain layered after base framing and before camera placement', 'Camera Director transforms must remain layered after base framing and before camera placement',
); );
}); });
// ── h3d-carve-9: setter class-killers (write-back pairs must survive DI) ────
// Severing the call turns the test RED: a silent local var replaces the
// write-back and the IIFE-scope var is never updated.
test('setCurX write-back is called in camUpdate (curX persists across frames)', () => {
// Silencing: sed 's/setCurX(curX)/\/\/ GUTTED/' → this test fails.
assert.match(
cameraSrc,
/setCurX\(\s*curX\s*\)/,
'camUpdate must write curX back via setCurX(); removing it silences the update',
);
});
test('setFretRowFitBoost write-back is called in camUpdate (boost persists across frames)', () => {
// Silencing: sed 's/setFretRowFitBoost(_fretRowFitBoost)/\/\/ GUTTED/' → RED.
assert.match(
cameraSrc,
/setFretRowFitBoost\(\s*_fretRowFitBoost\s*\)/,
'camUpdate must write _fretRowFitBoost back via setFretRowFitBoost(); removing it silences the boost',
);
});
// ── h3d-carve-9 Creed r1: naming-correspondence guard (param-swap class-killer) ──
// Structural source-scan: every entry in createCamera({...}) must satisfy its
// naming-correspondence class. Kills swaps like (CAM_H_BASE: CAM_DIST_BASE) and
// wrong-var getters ((getCurX: () => curDist)) across the whole wiring surface.
// cut-13 additions inherit the guard automatically; only the pinned fn-ref renames
// need a one-line entry in PINNED_RENAMES when a new rename is introduced.
test('createCamera({...}) wiring has correct naming correspondence (no param swaps)', () => {
// Fn-ref renames that intentionally differ from shorthand — pinned exhaustively.
const PINNED_RENAMES = {
freeCamFor: '_freeCamFor',
aspectPaneKey: '_aspectPaneKey',
resolveTuneFor: '_resolveTuneFor',
aspectRegisterPane: '_aspectRegisterPane',
};
// 1. Extract the argument block from the createCamera call.
// h3d-carve-13: destructure expanded with lookahead exports — update anchor string.
const ANCHOR = 'const { effectiveVfov, camUpdate, lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX } = createCamera({';
const callStart = src.indexOf(ANCHOR);
assert.ok(callStart >= 0, 'createCamera call must be findable in screen.js');
const blockStart = callStart + ANCHOR.length - 1; // points to the opening {
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, 'createCamera argument block must have balanced braces');
const inner = src.slice(blockStart + 1, blockEnd);
// 2. Split into entries at depth-0 commas (setter bodies contain { } — skip them).
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());
// Strip line comments and blank entries.
const entries = rawEntries
.map(e => e.replace(/\/\/[^\n]*/g, '').trim())
.filter(Boolean);
assert.ok(entries.length >= 48, `expected at least 48 entries, got ${entries.length}`);
const violations = [];
for (const entry of entries) {
if (!entry.includes(':')) {
// Shorthand — key === value by definition (BASE_VFOV, sY, …).
continue;
}
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')) {
// () => [_]varStem — varStem (no underscore) must match key minus 'get' prefix.
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;
}
if (key.startsWith('set')) {
// (v) => { [_]varStem = v; } — varStem must match key minus 'set' prefix.
const expectedStem = key[3].toLowerCase() + key.slice(4);
const m = value.match(/^\(v\)\s*=>\s*\{\s*_?(\w+)\s*=\s*v\s*;\s*\}$/);
if (!m) {
violations.push(`${key}: setter value '${value}' does not match (v) => { [_]var = v; }`);
continue;
}
if (m[1] !== expectedStem) {
violations.push(`${key}: setter assigns var stem '${m[1]}' but expected '${expectedStem}'`);
}
continue;
}
// key:value form that is NOT a getter, setter, or pinned rename — disallowed.
violations.push(`${key}: key:value entry not in PINNED_RENAMES and not a get/set arrow`);
}
assert.deepEqual(violations, [], `createCamera wiring violations found`);
});
+19 -34
View File
@@ -23,12 +23,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-15: U-section moved to renderer.js; scan both.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// h3d-carve-9: camUpdate body moved here; tests that pin its internals retarget to cameraSrc.
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8');
// ── Zoom-dependent framing ────────────────────────────────────────────────── // ── Zoom-dependent framing ──────────────────────────────────────────────────
@@ -48,14 +43,13 @@ test('cam.position uses interpolated framing multipliers, not literals', () => {
// The base position is assigned into _camX/_camY/_camZ so the opt-in // The base position is assigned into _camX/_camY/_camZ so the opt-in
// free-camera bridge (#771) can layer orbit/zoom/height on top before the // free-camera bridge (#771) can layer orbit/zoom/height on top before the
// single cam.position.set; the multipliers must still feed _camY/_camZ. // single cam.position.set; the multipliers must still feed _camY/_camZ.
// h3d-carve-9: camUpdate (and these locals) moved to src/camera.js.
assert.match( assert.match(
cameraSrc, src,
/_camX\s*=\s*curX\s*\+\s*shoulderOffset\s*,\s*_camY\s*=\s*h\s*\*\s*_hMul\s*,\s*_camZ\s*=\s*dist\s*\*\s*_dMul/, /_camX\s*=\s*curX\s*\+\s*shoulderOffset\s*,\s*_camY\s*=\s*h\s*\*\s*_hMul\s*,\s*_camZ\s*=\s*dist\s*\*\s*_dMul/,
'the base camera position must use the interpolated _hMul / _dMul multipliers', 'the base camera position must use the interpolated _hMul / _dMul multipliers',
); );
assert.match( assert.match(
cameraSrc, src,
/cam\.position\.set\(\s*_camX\s*,\s*_camY\s*,\s*_camZ\s*\)/, /cam\.position\.set\(\s*_camX\s*,\s*_camY\s*,\s*_camZ\s*\)/,
'cam.position.set must apply the computed _camX / _camY / _camZ', 'cam.position.set must apply the computed _camX / _camY / _camZ',
); );
@@ -63,19 +57,18 @@ test('cam.position uses interpolated framing multipliers, not literals', () => {
test('framing multipliers are a clamped zoom-distance interpolation', () => { test('framing multipliers are a clamped zoom-distance interpolation', () => {
// _zt is clamped to [0,1] and lerps each multiplier between NEAR and FAR. // _zt is clamped to [0,1] and lerps each multiplier between NEAR and FAR.
// h3d-carve-9: camUpdate (and these expressions) moved to src/camera.js.
assert.match( assert.match(
cameraSrc, src,
/Math\.max\(0,\s*Math\.min\(1,[\s\S]*?CAM_FRAME_DIST_NEAR[\s\S]*?CAM_FRAME_DIST_FAR/, /Math\.max\(0,\s*Math\.min\(1,[\s\S]*?CAM_FRAME_DIST_NEAR[\s\S]*?CAM_FRAME_DIST_FAR/,
'_zt must clamp (dist - NEAR)/(FAR - NEAR) into [0,1]', '_zt must clamp (dist - NEAR)/(FAR - NEAR) into [0,1]',
); );
assert.match( assert.match(
cameraSrc, src,
/CAM_FRAME_H_NEAR\s*\+\s*\(\s*CAM_FRAME_H_FAR\s*-\s*CAM_FRAME_H_NEAR\s*\)\s*\*\s*_zt/, /CAM_FRAME_H_NEAR\s*\+\s*\(\s*CAM_FRAME_H_FAR\s*-\s*CAM_FRAME_H_NEAR\s*\)\s*\*\s*_zt/,
'height multiplier must lerp NEAR->FAR by _zt', 'height multiplier must lerp NEAR->FAR by _zt',
); );
assert.match( assert.match(
cameraSrc, src,
/CAM_FRAME_D_NEAR\s*\+\s*\(\s*CAM_FRAME_D_FAR\s*-\s*CAM_FRAME_D_NEAR\s*\)\s*\*\s*_zt/, /CAM_FRAME_D_NEAR\s*\+\s*\(\s*CAM_FRAME_D_FAR\s*-\s*CAM_FRAME_D_NEAR\s*\)\s*\*\s*_zt/,
'depth multiplier must lerp NEAR->FAR by _zt', 'depth multiplier must lerp NEAR->FAR by _zt',
); );
@@ -92,38 +85,35 @@ test('lookahead window is expressed in measures with a seconds fallback', () =>
test('measure-start cache only keeps beats with measure >= 0', () => { test('measure-start cache only keeps beats with measure >= 0', () => {
// Intra-measure beats carry measure === -1 and must be skipped. // Intra-measure beats carry measure === -1 and must be skipped.
// h3d-carve-15: bare _measureStarts = _ms → setMeasureStarts(_ms) in renderer.js
assert.match( assert.match(
src, src,
/Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?setMeasureStarts\s*\(\s*_ms\s*\)/, /Number\.isFinite\(\s*_b\.measure\s*\)\s*&&\s*_b\.measure\s*>=\s*0[\s\S]*?_measureStarts\s*=\s*_ms/,
'only measure-start beats (measure >= 0) feed _measureStarts', 'only measure-start beats (measure >= 0) feed _measureStarts',
); );
}); });
test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => { test('lookaheadEndTime targets the measure CAM_LOOKAHEAD_MEASURES ahead', () => {
// h3d-carve-13: lookaheadEndTime moved to src/camera.js — retarget to cameraSrc.
assert.match( assert.match(
cameraSrc, src,
/function\s+lookaheadEndTime\s*\(\s*now\s*\)/, /function\s+lookaheadEndTime\s*\(\s*now\s*\)/,
'lookaheadEndTime(now) helper must exist', 'lookaheadEndTime(now) helper must exist',
); );
assert.match( assert.match(
cameraSrc, src,
/const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/, /const\s+targetIdx\s*=\s*curIdx\s*\+\s*CAM_LOOKAHEAD_MEASURES/,
'target measure index = current measure + CAM_LOOKAHEAD_MEASURES', 'target measure index = current measure + CAM_LOOKAHEAD_MEASURES',
); );
// No beats → seconds fallback. // No beats → seconds fallback.
assert.match( assert.match(
cameraSrc, src,
/if\s*\(\s*!ms\s*\|\|\s*ms\.length\s*===\s*0\s*\)\s*return\s+now\s*\+\s*CAM_LOOKAHEAD_SEC/, /if\s*\(\s*!ms\s*\|\|\s*ms\.length\s*===\s*0\s*\)\s*return\s+now\s*\+\s*CAM_LOOKAHEAD_SEC/,
'lookaheadEndTime must fall back to seconds when there are no measures', 'lookaheadEndTime must fall back to seconds when there are no measures',
); );
}); });
test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => { test('fret-bounds scan drives its window off lookaheadEndTime, not fixed seconds', () => {
// h3d-carve-13: lookaheadComputeFretBounds moved to src/camera.js — retarget to cameraSrc.
assert.match( assert.match(
cameraSrc, src,
/function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/, /function\s+lookaheadComputeFretBounds[\s\S]*?const\s+tEnd\s*=\s*lookaheadEndTime\(\s*now\s*\)/,
'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)', 'lookaheadComputeFretBounds must derive tEnd from lookaheadEndTime(now)',
); );
@@ -133,10 +123,9 @@ test('measure-start cache is invalidated on song change', () => {
// The song-change reset (reconnect path) resets _camSnapped; it must also // The song-change reset (reconnect path) resets _camSnapped; it must also
// drop the measure-start cache, otherwise lookaheadEndTime sizes the window // drop the measure-start cache, otherwise lookaheadEndTime sizes the window
// off the previous song's measure grid and over-zooms the first-data snap. // off the previous song's measure grid and over-zooms the first-data snap.
// h3d-carve-15: bare assignments → setter calls in renderer.js
assert.match( assert.match(
src, src,
/setCamSnapped\s*\(\s*false\s*\)\s*;[\s\S]*?setMeasureStarts\s*\(\s*\[\]\s*\)\s*;\s*setMeasureStartsRef\s*\(\s*null\s*\)\s*;/, /_camSnapped\s*=\s*false\s*;[\s\S]*?_measureStarts\s*=\s*\[\]\s*;\s*_measureStartsRef\s*=\s*null\s*;/,
'song-change reset must clear _measureStarts / _measureStartsRef alongside _camSnapped', 'song-change reset must clear _measureStarts / _measureStartsRef alongside _camSnapped',
); );
}); });
@@ -157,32 +146,29 @@ test('fret-row fit guard constants are defined', () => {
test('the curDist lerp target applies the fit-guard dolly boost', () => { test('the curDist lerp target applies the fit-guard dolly boost', () => {
// The span-driven tgtDist still owns zooming in; the boost only pulls back. // The span-driven tgtDist still owns zooming in; the boost only pulls back.
// h3d-carve-9: camUpdate (and this expression) moved to src/camera.js.
// tgtDist is DI-rewired to getTgtDist() direct call.
assert.match( assert.match(
cameraSrc, src,
/curDist\s*\+=\s*\(\s*getTgtDist\(\)\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/, /curDist\s*\+=\s*\(\s*tgtDist\s*\*\s*_fretRowFitBoost\s*-\s*curDist\s*\)\s*\*\s*lerp/,
'curDist must lerp toward getTgtDist() * _fretRowFitBoost', 'curDist must lerp toward tgtDist * _fretRowFitBoost',
); );
}); });
test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => { test('the guard projects the fret-row band and adjusts the boost with hysteresis', () => {
// Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4). // Row band Y mirrors the render position (sY(lowest) - S_GAP * 1.4).
// h3d-carve-9: camUpdate (and this logic) moved to src/camera.js.
assert.match( assert.match(
cameraSrc, src,
/Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/, /Math\.min\(\s*sY\(0\)\s*,\s*sY\(nStr\s*-\s*1\)\s*\)\s*-\s*S_GAP\s*\*\s*1\.4/,
'the guard must probe the same row band the fret-number row is drawn at', 'the guard must probe the same row band the fret-number row is drawn at',
); );
// Prompt pull-back when below the min, capped at BOOST_MAX. // Prompt pull-back when below the min, capped at BOOST_MAX.
assert.match( assert.match(
cameraSrc, src,
/_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/, /_rowNdcY\s*<\s*FRET_ROW_FIT_NDC_MIN[\s\S]*?Math\.min\(\s*FRET_ROW_FIT_BOOST_MAX/,
'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX', 'below the min NDC the boost rises, capped at FRET_ROW_FIT_BOOST_MAX',
); );
// Lazy relax only once past the deadband, floored at 1. // Lazy relax only once past the deadband, floored at 1.
assert.match( assert.match(
cameraSrc, src,
/_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/, /_rowNdcY\s*>\s*FRET_ROW_FIT_NDC_MIN\s*\+\s*FRET_ROW_FIT_DEADBAND[\s\S]*?Math\.max\(\s*1\s*,\s*_fretRowFitBoost/,
'past the deadband the boost relaxes back toward 1', 'past the deadband the boost relaxes back toward 1',
); );
@@ -190,9 +176,8 @@ test('the guard projects the fret-row band and adjusts the boost with hysteresis
test('the fit guard yields to the free-cam (Camera Director)', () => { test('the fit guard yields to the free-cam (Camera Director)', () => {
// When the free-cam owns the view the auto dolly must reset to 1, not fight it. // When the free-cam owns the view the auto dolly must reset to 1, not fight it.
// h3d-carve-9: camUpdate (and this guard) moved to src/camera.js.
assert.match( assert.match(
cameraSrc, src,
/if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/, /if\s*\(\s*_freeCam\s*&&\s*_freeCam\.enabled\s*\)\s*\{\s*if\s*\(\s*_fretRowFitBoost\s*!==\s*1\s*\)\s*_fretRowFitBoost\s*=\s*1/,
'with the free-cam enabled the guard must drop any auto dolly back to 1', 'with the free-cam enabled the guard must drop any auto dolly back to 1',
); );
@@ -1,271 +0,0 @@
// Behavioral kills for h3d-carve-13: S-section lookahead helpers extracted
// into src/camera.js (createCamera). Covers lookaheadEndTime (via callers),
// lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX.
//
// Kill strategy per god's GO:
// lookaheadComputeFretBounds — real-shaped note/chord/anchor fixtures asserting
// concrete min/max fret bounds; gut the bounds loop → RED.
// lookaheadBootstrapTime / lookaheadTargetWorldX — one input→output assert each;
// gut the function → RED.
// lookaheadEndTime (private) — exercised through its callers.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
// ── Source-level checks ────────────────────────────────────────────────────
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js');
test('camera.js exports createCamera that returns all 5 expected symbols', () => {
const src = fs.readFileSync(CAMERA_JS, 'utf8');
assert.match(src, /return\s*\{[^}]*effectiveVfov/, 'effectiveVfov in return');
assert.match(src, /return\s*\{[^}]*camUpdate/, 'camUpdate in return');
assert.match(src, /return\s*\{[^}]*lookaheadBootstrapTime/, 'lookaheadBootstrapTime in return');
assert.match(src, /return\s*\{[^}]*lookaheadComputeFretBounds/, 'lookaheadComputeFretBounds in return');
assert.match(src, /return\s*\{[^}]*lookaheadTargetWorldX/, 'lookaheadTargetWorldX in return');
});
test('camera.js DI signature includes 9 new h3d-carve-13 params', () => {
const src = fs.readFileSync(CAMERA_JS, 'utf8');
for (const param of [
'NFRETS', 'CAM_LOOKAHEAD_MEASURES', 'CAM_LOOKAHEAD_SEC', 'CAM_FRET_EDGE_BLEND',
'getMeasureStarts', 'validString', 'getChartAnchorAt', 'xFretMid', 'xFret',
]) {
assert.match(src, new RegExp(`\\b${param}\\b`), `param ${param} present in camera.js`);
}
});
test('lookaheadEndTime is NOT exported (factory-private)', () => {
const src = fs.readFileSync(CAMERA_JS, 'utf8');
// Must not appear in the return object
assert.doesNotMatch(
src,
/return\s*\{[^}]*lookaheadEndTime/,
'lookaheadEndTime must not be in the return object',
);
// But must be defined as a function inside the factory
assert.match(src, /function\s+lookaheadEndTime\s*\(/, 'lookaheadEndTime defined in factory');
});
test('screen.js Region A contains the carve comment and not the old function defs', () => {
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Old function defs removed
assert.doesNotMatch(src, /function\s+lookaheadEndTime\s*\(/, 'lookaheadEndTime not in screen.js');
assert.doesNotMatch(src, /function\s+lookaheadBootstrapTime\s*\(/, 'lookaheadBootstrapTime not in screen.js');
assert.doesNotMatch(src, /function\s+lookaheadComputeFretBounds\s*\(/, 'lookaheadComputeFretBounds not in screen.js');
assert.doesNotMatch(src, /function\s+lookaheadTargetWorldX\s*\(/, 'lookaheadTargetWorldX not in screen.js');
// Carve comment present
assert.match(src, /h3d-carve-13.*S-section lookahead helpers/, 'carve-13 comment present');
// Destructure at createCamera call site
assert.match(src, /lookaheadBootstrapTime.*lookaheadComputeFretBounds.*lookaheadTargetWorldX.*=\s*createCamera|createCamera[\s\S]{0,800}lookaheadBootstrapTime/, 'exports destructured from createCamera');
});
// ── Behavioral kills (module-level, import camera.js via dynamic import) ──
// Minimal stub factory matching the 9 new DI params + pre-existing required params.
// Only the fields actually used by the 4 new functions need real values;
// everything else gets a no-op stub so createCamera doesn't throw.
function makeCamera(overrides = {}) {
const fretWidth = 10; // arbitrary unit
// fretX: fret 0 = 0, fret N = N*fretWidth
const fretX = f => f * fretWidth;
const fretMid = f => (f + 0.5) * fretWidth;
return import(`${CAMERA_JS}?t=${Date.now()}`).then(mod => {
return mod.createCamera({
// Pre-existing DI params (stubs — camUpdate not exercised here)
BASE_VFOV: 60, HORPLUS_START_ASPECT: 1.78, HORPLUS_MIN_VFOV: 30,
CAM_LERP_BASE: 0.05, CAM_H_BASE: 1, CAM_DIST_BASE: 10,
CAM_FRAME_DIST_NEAR: 5, CAM_FRAME_DIST_FAR: 20,
CAM_FRAME_H_NEAR: 1, CAM_FRAME_H_FAR: 2,
CAM_FRAME_D_NEAR: 1, CAM_FRAME_D_FAR: 1.5,
FOCUS_D: 8, S_GAP: 1, K: 1,
FRET_ROW_FIT_NDC_MIN: -0.9, FRET_ROW_FIT_DEADBAND: 0.1, FRET_ROW_FIT_BOOST_MAX: 1.5,
CAM_TILT_BAND_T: 0.1, CAM_TILT_BAND_C: 0.3, CAM_TILT_STR_T: 0.5, CAM_TILT_STR_C: 0.2,
getCam: () => ({ fov: 60, updateProjectionMatrix() {}, position: { set() {} }, lookAt() {}, updateMatrixWorld() {} }),
getTgtX: () => 0, getTgtDist: () => 10,
getAspectScale: () => 1, getLeftyCached: () => false,
getNStr: () => 6, getProbe: () => ({ set() {}, project() {}, y: -0.35 }),
getTiltSmoothing: () => 0.5, getPaneAspect: () => 1.78, getPaneUid: () => 'test',
getHighwayCanvas: () => null,
getCurX: () => 0, setCurX: () => {},
getCurDist: () => 10, setCurDist: () => {},
getCurLookY: () => 0, setCurLookY: () => {},
getTgtLookY: () => 0, setTgtLookY: () => {},
getFretRowFitBoost: () => 1, setFretRowFitBoost: () => {},
sY: () => 0, freeCamFor: () => null,
aspectPaneKey: () => 'test', resolveTuneFor: () => null,
aspectRegisterPane: () => {},
// h3d-carve-13: new params
NFRETS: 24,
CAM_LOOKAHEAD_MEASURES: 9,
CAM_LOOKAHEAD_SEC: 3.0,
CAM_FRET_EDGE_BLEND: 0.1,
getMeasureStarts: overrides.getMeasureStarts ?? (() => []),
validString: overrides.validString ?? (s => s >= 0 && s < 6),
getChartAnchorAt: overrides.getChartAnchorAt ?? (() => null),
xFretMid: overrides.xFretMid ?? fretMid,
xFret: overrides.xFret ?? fretX,
});
});
}
// ── lookaheadComputeFretBounds — concrete note/chord/anchor fixtures ────────
test('lookaheadComputeFretBounds: returns null when no notes/chords/anchors', async () => {
const cam = await makeCamera();
const result = cam.lookaheadComputeFretBounds(0, [], [], []);
assert.equal(result, null);
});
test('lookaheadComputeFretBounds: fret bounds from notes array', async () => {
const cam = await makeCamera();
// now=0, CAM_LOOKAHEAD_SEC=3.0 → window [0, 3.0]
const notes = [
{ t: 0.5, s: 0, f: 5 },
{ t: 1.0, s: 1, f: 9 },
{ t: 1.5, s: 2, f: 3 },
{ t: 10, s: 0, f: 1 }, // outside window — should be excluded
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.ok(result !== null, 'should find bounds');
assert.equal(result.minF, 3, 'minF = 3 (fret 3 note at t=1.5)');
assert.equal(result.maxF, 9, 'maxF = 9 (fret 9 note at t=1.0)');
});
test('lookaheadComputeFretBounds: fret bounds from chords', async () => {
const cam = await makeCamera();
const chords = [
{ t: 0.5, notes: [{ s: 0, f: 2 }, { s: 1, f: 7 }] },
{ t: 4.0, notes: [{ s: 0, f: 1 }] }, // outside window
];
const result = cam.lookaheadComputeFretBounds(0, null, null, chords);
assert.ok(result !== null);
assert.equal(result.minF, 2);
assert.equal(result.maxF, 7);
});
test('lookaheadComputeFretBounds: open strings (f=0) are excluded', async () => {
const cam = await makeCamera();
// f=0 means open string — consider() guard: !(f > 0) → skip
const notes = [
{ t: 0.5, s: 0, f: 0 }, // open — must be excluded
{ t: 1.0, s: 1, f: 8 },
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.ok(result !== null);
assert.equal(result.minF, 8, 'open string excluded');
assert.equal(result.maxF, 8);
});
test('lookaheadComputeFretBounds: invalid string filtered by validString', async () => {
let nStr = 6;
const cam = await makeCamera({
validString: s => s >= 0 && s < nStr,
});
const notes = [
{ t: 0.5, s: 99, f: 5 }, // invalid string → skip
{ t: 1.0, s: 2, f: 12 },
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.ok(result !== null);
assert.equal(result.minF, 12, 'invalid-string note excluded');
assert.equal(result.maxF, 12);
});
test('lookaheadComputeFretBounds: anchor data contributes to bounds', async () => {
// Anchor: fret=3, width=4 → frets 3..6
const anchor = { fret: 3, width: 4, time: 0 };
const cam = await makeCamera({
getChartAnchorAt: (_arr, _t) => anchor,
});
// Tiny range so the anchor loop runs [0..3.0] with step 0.125
const result = cam.lookaheadComputeFretBounds(0, [anchor], null, null);
assert.ok(result !== null);
assert.equal(result.minF, 3);
assert.equal(result.maxF, 6);
});
// Kill test: gut the bounds loop → result always null or wrong bounds
test('lookaheadComputeFretBounds: gut-kill — notes outside window excluded (timing boundary)', async () => {
const cam = await makeCamera();
// now=0, window=3s; note at exactly t=3.0+ε should be excluded
const notes = [
{ t: 3.001, s: 0, f: 5 }, // just outside window
];
const result = cam.lookaheadComputeFretBounds(0, null, notes, null);
assert.equal(result, null, 'note beyond window must be excluded');
});
// ── lookaheadBootstrapTime — input→output with gut-kill ────────────────────
test('lookaheadBootstrapTime: returns now when lookahead already covers eventTime', async () => {
const cam = await makeCamera();
// now=0, CAM_LOOKAHEAD_SEC=3.0 → lookaheadEndTime(0)=3.0 ≥ eventTime=2.0
const result = cam.lookaheadBootstrapTime(0, 2.0);
assert.equal(result, 0, 'window already covers event → return now');
});
test('lookaheadBootstrapTime: returns now when eventTime ≤ now', async () => {
const cam = await makeCamera();
const result = cam.lookaheadBootstrapTime(5.0, 4.0);
assert.equal(result, 5.0, 'event in the past → return now');
});
test('lookaheadBootstrapTime: binary search converges on correct bootstrap point', async () => {
// measureStarts at [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
// CAM_LOOKAHEAD_MEASURES=9 → from t=0, window end = ms[9] = 18
// eventTime=19 → need to project forward until lookaheadEnd(t) ≥ 19
// lookaheadEnd(2) = ms[2+9] = ms[11]... but ms only has 11 entries [0..10] → extrapolate
// The exact value is tested for being in [0, eventTime) and for t < eventTime.
const ms = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20];
const cam = await makeCamera({ getMeasureStarts: () => ms });
const bst = cam.lookaheadBootstrapTime(0, 28.0);
// lookaheadEnd(bst) must be >= 28.0
// bst must be > 0 (event is past current window)
assert.ok(bst > 0, 'bootstrap > 0: needed to project forward');
assert.ok(bst < 28.0, 'bootstrap < eventTime');
});
// ── lookaheadTargetWorldX — input→output with gut-kill ─────────────────────
test('lookaheadTargetWorldX: blends fret midpoint with board-weighted X', async () => {
// xFretMid(f) = (f+0.5)*10, xFret(f) = f*10, NFRETS=24
// minF=3, maxF=9:
// middle = (xFretMid(3) + xFretMid(9))/2 = (35 + 95)/2 = 65
// weighted = 0.6*xFret(0) + 0.4*xFret(24) = 0 + 0.4*240 = 96
// wb=0.1 → result = 65*(1-0.1) + 96*0.1 = 58.5 + 9.6 = 68.1
const cam = await makeCamera();
const result = cam.lookaheadTargetWorldX(3, 9);
assert.ok(Math.abs(result - 68.1) < 0.001, `expected ≈68.1, got ${result}`);
});
test('lookaheadTargetWorldX: symmetric fret span centered at board center', async () => {
// With symmetric span (frets 0..24) middle = xFretMid(0)+xFretMid(24))/2 = (5+245)/2=125
// weighted = 0.6*0 + 0.4*240 = 96; wb=0.1 → 125*0.9 + 96*0.1 = 112.5+9.6=122.1
const cam = await makeCamera();
const r1 = cam.lookaheadTargetWorldX(0, 24);
assert.ok(Math.abs(r1 - 122.1) < 0.001, `symmetric span expected ≈122.1, got ${r1}`);
});
// ── Wiring guard: naming-correspondence on the 9 new DI params ────────────
test('createCamera DI params appear in the h3d-carve-13 comment block in screen.js', () => {
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// All 9 new params must appear in the createCamera({}) call block.
// Find the call site (the `const { ... } = createCamera({` line), then
// extract from there to the matching closing `});` — search for the
// h3d-carve-13 comment marker which brackets the new params.
const idx = src.indexOf('const { effectiveVfov, camUpdate, lookaheadBootstrapTime');
assert.ok(idx !== -1, 'createCamera destructure line found');
// Pull enough text to cover the full call (up to 4000 chars is plenty)
const callBlock = src.slice(idx, idx + 4000);
for (const name of [
'NFRETS', 'CAM_LOOKAHEAD_MEASURES', 'CAM_LOOKAHEAD_SEC', 'CAM_FRET_EDGE_BLEND',
'getMeasureStarts', 'validString', 'getChartAnchorAt', 'xFretMid', 'xFret',
]) {
assert.ok(callBlock.includes(name), `${name} present in createCamera({}) call`);
}
});
+5 -9
View File
@@ -14,26 +14,22 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16 const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => { test('binds webglcontextlost + webglcontextrestored on the renderer canvas', () => {
// h3d-carve-16: DI form uses getRen().domElement instead of ren.domElement assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
assert.match(src, /(?:getRen\(\)|ren)\.domElement\.addEventListener\(\s*['"]webglcontextlost['"]/,
'must listen for webglcontextlost on ren.domElement (the WebGL canvas)'); 'must listen for webglcontextlost on ren.domElement (the WebGL canvas)');
assert.match(src, /(?:getRen\(\)|ren)\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/, assert.match(src, /ren\.domElement\.addEventListener\(\s*['"]webglcontextrestored['"]/,
'must listen for webglcontextrestored on ren.domElement'); 'must listen for webglcontextrestored on ren.domElement');
}); });
test('the context-lost handler preventDefaults and pauses drawing', () => { test('the context-lost handler preventDefaults and pauses drawing', () => {
// Without preventDefault() the browser will not attempt to restore the // Without preventDefault() the browser will not attempt to restore the
// context and the loss can escalate to a renderer crash. // context and the loss can escalate to a renderer crash.
// h3d-carve-16: DI form uses setOnCtxLost((e) => instead of _onCtxLost = (e) => const m = src.match(/_onCtxLost\s*=\s*\(e\)\s*=>\s*\{[\s\S]*?\};/);
const m = src.match(/(?:_onCtxLost\s*=\s*|setOnCtxLost\()\s*\(e\)\s*=>\s*\{[\s\S]*?\}\s*[;)]/);
assert.ok(m, '_onCtxLost handler must exist'); assert.ok(m, '_onCtxLost handler must exist');
assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()'); assert.match(m[0], /preventDefault\(\)/, 'context-lost handler must call preventDefault()');
// h3d-carve-16: DI form uses setCtxLost(true) instead of _ctxLost = true assert.match(m[0], /_ctxLost\s*=\s*true/, 'context-lost handler must set _ctxLost = true');
assert.match(m[0], /(?:setCtxLost\(true\)|_ctxLost\s*=\s*true)/, 'context-lost handler must set _ctxLost = true');
}); });
test('draw() early-returns while the context is lost', () => { test('draw() early-returns while the context is lost', () => {
@@ -23,19 +23,11 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-14: V-section moved to note-renderer.js; tests that pin its
// patterns must now search both files.
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
let _src; let _src;
/** Returns screen.js + note-renderer.js concatenated for pattern matching. */ /** Returns the cached 3D highway screen source under test. */
function src() { function src() {
if (!_src) { if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
_src = fs.readFileSync(SCREEN_JS, 'utf8')
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8')
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
}
return _src; return _src;
} }
+1 -2
View File
@@ -17,11 +17,10 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
let _src; let _src;
function src() { function src() {
if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
return _src; return _src;
} }
+11 -24
View File
@@ -2,12 +2,8 @@
// The board can render fret columns either Uniform (equal width, the chart // The board can render fret columns either Uniform (equal width, the chart
// Remastered style) or Logarithmic (real instrument geometry), switchable at // Remastered style) or Logarithmic (real instrument geometry), switchable at
// runtime via window.h3dSetFretSpacing and persisted in localStorage. A // runtime via window.h3dSetFretSpacing and persisted in localStorage. A
// refactor that renames the storage key, drops the delegator in fretX, or // refactor that renames the storage key, drops the uniform/log branch in
// stops validating the mode would silently regress the setting. // fretX, or stops validating the mode would silently regress the setting.
//
// Since h3d-carve-1, the uniform/log branch lives in src/geometry.js
// (geoFretX); screen.js keeps a 1-arg delegator:
// const fretX = f => geoFretX(f, _h3dFretUniform);
// //
// Source-level only — same strategy as the other tests/js/ files. // Source-level only — same strategy as the other tests/js/ files.
@@ -17,11 +13,9 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key', () => { test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/_h3dFretUniform\s*=\s*localStorage\.getItem\(\s*'highway_3d\.fretSpacing'\s*\)\s*!==\s*'logarithmic'/, /_h3dFretUniform\s*=\s*localStorage\.getItem\(\s*'highway_3d\.fretSpacing'\s*\)\s*!==\s*'logarithmic'/,
@@ -29,25 +23,19 @@ test('fret-spacing mode is read from the highway_3d.fretSpacing localStorage key
); );
}); });
test('fretX is a 1-arg delegator to geoFretX in screen.js (h3d-carve-1)', () => { test('fretX switches between the uniform and logarithmic implementations', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/const\s+fretX\s*=\s*f\s*=>\s*geoFretX\(\s*f\s*,\s*_h3dFretUniform\s*\)/, /const\s+fretX\s*=\s*f\s*=>\s*_h3dFretUniform\s*\?\s*_fretXUni\(f\)\s*:\s*_fretXLog\(f\)/,
'screen.js fretX must delegate to geoFretX(f, _h3dFretUniform) from geometry.js', 'fretX must pick _fretXUni when _h3dFretUniform else _fretXLog',
);
const geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
assert.match(
geo,
/export\s+const\s+geoFretX\s*=\s*\(\s*f\s*,\s*uniform\s*\)\s*=>/,
'geometry.js must export geoFretX as a 2-arg function (fret, uniform)',
); );
}); });
test('h3dSetFretSpacing validates the mode against the two supported values', () => { test('h3dSetFretSpacing validates the mode against the two supported values', () => {
// An unexpected input must not be persisted verbatim — it is coerced to // An unexpected input must not be persisted verbatim — it is coerced to
// one of 'logarithmic' | 'uniform' before writing to localStorage. // one of 'logarithmic' | 'uniform' before writing to localStorage.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?mode\s*===\s*'logarithmic'\s*\?\s*'logarithmic'\s*:\s*'uniform'[\s\S]*?localStorage\.setItem\(\s*'highway_3d\.fretSpacing'/, /window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?mode\s*===\s*'logarithmic'\s*\?\s*'logarithmic'\s*:\s*'uniform'[\s\S]*?localStorage\.setItem\(\s*'highway_3d\.fretSpacing'/,
@@ -61,7 +49,7 @@ test('h3dSetFretSpacing applies the change live, not via a page reload', () => {
// a 'fretSpacing' change so mounted panels rebuild in place — same path as // a 'fretSpacing' change so mounted panels rebuild in place — same path as
// every other 3D-highway setting. Reintroducing location.reload() here is // every other 3D-highway setting. Reintroducing location.reload() here is
// the regression this guards against. // the regression this guards against.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
const setter = src.match(/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?\n \};/); const setter = src.match(/window\.h3dSetFretSpacing\s*=\s*mode\s*=>\s*\{[\s\S]*?\n \};/);
assert.ok(setter, 'h3dSetFretSpacing assignment must be present'); assert.ok(setter, 'h3dSetFretSpacing assignment must be present');
assert.doesNotMatch( assert.doesNotMatch(
@@ -77,11 +65,10 @@ test('h3dSetFretSpacing applies the change live, not via a page reload', () => {
}); });
test('the fretSpacing change rebuilds a mounted board live', () => { test('the fretSpacing change rebuilds a mounted board live', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-16: DI form uses getFretG() getter
assert.match( assert.match(
src, src,
/changedKey\s*===\s*'fretSpacing'[\s\S]*?if\s*\((?:fretG|getFretG\(\))\)\s*buildBoard\(\)/, /changedKey\s*===\s*'fretSpacing'[\s\S]*?if\s*\(fretG\)\s*buildBoard\(\)/,
'the panel bg listener must rebuild the board when fretSpacing changes', 'the panel bg listener must rebuild the board when fretSpacing changes',
); );
}); });
-374
View File
@@ -1,374 +0,0 @@
// Source + behavioural guards for h3d-carve-8: Q-helpers (lighting/FX utilities)
// extracted to src/fx.js.
//
// Class-killers guaranteed:
// 1. Module exports createFx (source)
// 2. createFx return set covers all 7 required symbols (source)
// 3. Stranded-caller: every returned symbol in screen.js destructure (source)
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
// 5. _timingHex returns EARLY tint when ts='EARLY' and _timingFx truthy (behavioural)
// 6. _sparkBurst writes to getSparkPos() array NOT a stale init-time capture
// (live-accessor class-killer: set new arrays after factory init → RED if stale-cached)
// 7. _sparkUpdate sets needsUpdate=true on pts reached via live getSparkPts()
// (F2 fix: AND assertions; no longer vacuously true via .visible === false)
// 8. _bloomEnsure calls setBloomLoad (synchronous write-back: assignment silenced → RED)
// 8b. _bloomEnsure reads getComposer() live (not init-cached: setComposer after init → RED)
// 9. _applyBloom calls all 4 write-backs (setBloomPass/setBloomW/setBloomH/setComposer)
// (F1 fix: named function testable with mock modules; each sever → RED)
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const FX_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'fx.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function stripComments(s) {
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
}
function src() { return fs.readFileSync(FX_JS, 'utf8'); }
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
// ── 1. Module exports createFx ───────────────────────────────────────────────
test('fx.js exports createFx', () => {
assert.match(src(), /export\s+function\s+createFx\s*\(/);
});
// ── 2. Return set covers all 6 required symbols ──────────────────────────────
test('createFx returns all 6 required symbols', () => {
const stripped = stripComments(src());
const REQUIRED = ['_h3dHexOrDefault', '_applyCinematic', '_timingHex', '_sparkBurst', '_sparkUpdate', '_applyBloom', '_bloomEnsure'];
// Factory-level return block: 4-space indent inside createFx body.
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of REQUIRED) {
assert.ok(returned.includes(sym), `return set must include ${sym}`);
}
});
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
test('every createFx returned symbol appears in screen.js destructure', () => {
const stripped = stripComments(src());
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
const scrRaw = screenSrc();
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createFx\s*\(/);
assert.ok(destrMatch, 'screen.js must have a createFx destructure');
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of returned) {
assert.ok(destructured.includes(sym),
`returned symbol '${sym}' must appear in screen.js createFx destructure`);
}
});
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
test('factory-private symbols in fx.js do not appear bare in screen.js', () => {
const stripped = stripComments(src());
// Collect factory-level return symbols.
const retMatch = stripped.match(/\n {4}return\s*\{\s*([^}]+)\}/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = new Set(retMatch[1].split(',').map(s => s.trim()).filter(Boolean));
// Factory-depth-1: exactly 4-space indented const/let inside createFx.
// (There are none in this module — all vars are per-call locals inside functions.)
const privateSyms = [];
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
const sym = m[1];
if (!returned.has(sym)) privateSyms.push(sym);
}
// Guard: if there ARE any factory-depth-1 privates, they must not be bare in screen.js.
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
scr = stripComments(scr);
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createFx\s*\([^)]*\)\s*;/, '');
const violations = privateSyms.filter(sym => new RegExp('\\b' + sym + '\\b').test(scr));
assert.deepStrictEqual(violations, [],
'screen.js must not reference factory-private fx.js symbols: ' + violations.join(', '));
});
// ── Behavioural vm sandbox ───────────────────────────────────────────────────
function loadFxModule(di) {
const raw = fs.readFileSync(FX_JS, 'utf8');
// Strip ES export keyword so the script runs in a vm context.
const code = raw.replace(/^export\s+function\s+createFx/m, 'function createFx');
// Provide Promise so Promise.all() in _bloomEnsure is defined.
// dynamic import() inside the vm will reject (no module resolution),
// but setBloomLoad() is called BEFORE the rejection fires — it receives
// the pending Promise synchronously, which is what the write-back test checks.
const sandbox = { console, Promise, __exports: {} };
vm.createContext(sandbox);
vm.runInContext(code + '\n__exports.createFx = createFx;', sandbox);
return sandbox.__exports.createFx(di);
}
function makeDi(overrides = {}) {
// Minimal valid DI for behavioural tests.
const state = {
ambLight: null, dirLight: null, _cinematic: false, _timingFx: false,
_sparkPts: null, _SPARK_N: 10,
_sparkPos: new Float32Array(30), _sparkVel: new Float32Array(30),
_sparkCol: new Float32Array(30), _sparkLife: new Float32Array(10),
_composer: null, _bloomLoad: null, _bloomPass: null, _bloomW: 0, _bloomH: 0,
ren: null, scene: null, cam: null, highwayCanvas: null,
T: null,
};
return Object.assign({
BG_DEFAULTS: { nutColor: '#cccccc' },
K: 0.01,
getT: () => state.T, getAmbLight: () => state.ambLight,
getDirLight: () => state.dirLight, getCinematic: () => state._cinematic,
getTimingFx: () => state._timingFx,
getSparkPts: () => state._sparkPts, setSparkPts: (v) => { state._sparkPts = v; },
getSparkN: () => state._SPARK_N,
getSparkPos: () => state._sparkPos, setSparkPos: (v) => { state._sparkPos = v; },
getSparkVel: () => state._sparkVel, setSparkVel: (v) => { state._sparkVel = v; },
getSparkCol: () => state._sparkCol, setSparkCol: (v) => { state._sparkCol = v; },
getSparkLife: () => state._sparkLife, setSparkLife: (v) => { state._sparkLife = v; },
getComposer: () => state._composer, setComposer: (v) => { state._composer = v; },
getBloomLoad: () => state._bloomLoad, setBloomLoad: (v) => { state._bloomLoad = v; },
getBloomPass: () => state._bloomPass, setBloomPass: (v) => { state._bloomPass = v; },
getBloomW: () => state._bloomW, setBloomW: (v) => { state._bloomW = v; },
getBloomH: () => state._bloomH, setBloomH: (v) => { state._bloomH = v; },
getRen: () => state.ren, getScene: () => state.scene,
getCam: () => state.cam, getHighwayCanvas: () => state.highwayCanvas,
canvasSize: () => ({ w: 800, h: 600 }),
_state: state,
}, overrides);
}
// ── 5. _timingHex returns EARLY tint when ts='EARLY' and timingFx truthy ─────
test('_timingHex returns EARLY hex when ts=EARLY and timingFx is truthy', () => {
// Mutation that goes RED: remove the EARLY branch → returns 0x22ff88 instead.
const di = makeDi();
di._state._timingFx = true;
const { _timingHex } = loadFxModule(di);
assert.equal(_timingHex('EARLY'), 0x35d6ff, 'EARLY timing must return cyan 0x35d6ff');
assert.equal(_timingHex('LATE'), 0xffb84d, 'LATE timing must return amber 0xffb84d');
assert.equal(_timingHex('OK'), 0x22ff88, 'OK timing must return green');
});
// ── 6. _sparkBurst live-accessor class-killer ─────────────────────────────────
test('_sparkBurst writes to the sparkPos array returned by getSparkPos (live, not init-cached)', () => {
// Mutation that goes RED: if _sparkBurst caches `const _sparkPos = getSparkPos()` at
// factory init time instead of per-call, then calling setSparkPos(newArray) after init
// and triggering a burst will write to the STALE array → newArray stays all zeros → RED.
const di = makeDi();
// Bootstrap: provide a sparkPts stub so _sparkBurst doesn't bail early.
di._state._sparkPts = { geometry: { attributes: { position: { needsUpdate: false }, color: { needsUpdate: false } } }, visible: false };
// Set a dead particle slot so _sparkBurst can spawn into it.
di._state._sparkLife[0] = 0;
const { _sparkBurst } = loadFxModule(di);
// Burst writes into initial array.
_sparkBurst(1, 2, 3, 0xff0000, 1);
const firstArr = di._state._sparkPos;
// At least x position should be set (= 1).
assert.equal(firstArr[0], 1, 'sparkPos[0] should be x=1 after burst into initial array');
// Now rebuild: replace sparkPos with a fresh zero array.
const newPos = new Float32Array(30);
di.setSparkPos(newPos);
// Reset life for slot 0 so burst fires again.
di._state._sparkLife[0] = 0;
_sparkBurst(5, 6, 7, 0x00ff00, 1);
assert.equal(newPos[0], 5,
'_sparkBurst must write into the NEW sparkPos after setSparkPos rebuild; ' +
'if 0 it cached the initial array at factory init time (live-accessor broken)');
});
// ── 7. _sparkUpdate live-accessor class-killer (F2 fix) ───────────────────────
test('_sparkUpdate sets needsUpdate=true on pts reached via live getSparkPts()', () => {
// Mutation that goes RED: if getSparkPts() result is cached at factory init,
// setSparkPts(newPts) after init → _sparkUpdate still references stale (null) pts
// → needsUpdate flags never set → BOTH assertions fail → RED.
//
// F2 fix: previous test used `|| pts.visible === false` which is vacuously true
// (no living sparks → visible stays false) — a NO-OP _sparkUpdate passed the test.
// Now we assert needsUpdate=true (set unconditionally after the loop) via AND.
const di = makeDi();
const { _sparkUpdate } = loadFxModule(di);
// No sparkPts yet — short-circuit (must not throw).
_sparkUpdate(0.016);
// Now provide sparkPts (simulates buildBoard completing after factory init).
const pts = {
geometry: { attributes: {
position: { needsUpdate: false },
color: { needsUpdate: false },
}},
visible: true,
};
di.setSparkPts(pts);
_sparkUpdate(0.016);
// _sparkUpdate sets needsUpdate unconditionally after the particle loop.
// If _sparkUpdate cached the stale null ptr at factory init, both stay false.
assert.ok(pts.geometry.attributes.position.needsUpdate === true,
'_sparkUpdate must set position.needsUpdate=true (reached via live getSparkPts)');
assert.ok(pts.geometry.attributes.color.needsUpdate === true,
'_sparkUpdate must set color.needsUpdate=true (reached via live getSparkPts)');
});
// ── 8. _bloomEnsure write-back class-killer: setBloomLoad called ──────────────
test('_bloomEnsure calls setBloomLoad when ren/scene/cam are available', () => {
// Mutation that goes RED: if _bloomEnsure does `const bl = Promise.all(...)` (local)
// instead of `setBloomLoad(Promise.all(...))`, getBloomLoad() stays null after the
// call → every subsequent frame re-enters init → duplicate composers → visual glitch.
// This is the synchronously verifiable half of the write-back contract.
const di = makeDi();
// Provide non-null ren/scene/cam so the guard passes.
di._state.ren = {}; di._state.scene = {}; di._state.cam = {};
di._state.T = { WebGLRenderTarget() {}, HalfFloatType: 1, Vector2() {} };
const { _bloomEnsure } = loadFxModule(di);
const result = _bloomEnsure();
assert.equal(result, null, '_bloomEnsure returns null on first call (async init started)');
assert.ok(di.getBloomLoad() instanceof Promise,
'setBloomLoad must have been called with the init Promise; ' +
'if getBloomLoad() is null the assignment silently became a local (write-back broken)');
// A second call must short-circuit on the existing bloomLoad (not start a second init).
const result2 = _bloomEnsure();
assert.equal(result2, null, 'second call must return null (init still in flight, not re-started)');
});
// ── 8b. _bloomEnsure getComposer live-accessor ────────────────────────────────
test('_bloomEnsure returns composer set via setComposer (reads live via getComposer)', () => {
// Mutation that goes RED: if _bloomEnsure caches `const _composer = getComposer()`
// at factory init time, a later setComposer(comp) is invisible → returns null forever.
const di = makeDi();
const { _bloomEnsure } = loadFxModule(di);
// Initially null.
assert.equal(_bloomEnsure(), null, 'must return null when composer not yet set');
// Simulate bloom async chain resolving.
const fakeComp = { render() {}, setSize() {} };
di.setComposer(fakeComp);
// Now must return the composer (reads via live getComposer(), not init-cached).
assert.equal(_bloomEnsure(), fakeComp,
'_bloomEnsure must return composer set via setComposer; ' +
'null means it cached the initial null value at factory init time');
});
// ── 9. _applyBloom calls all 4 write-backs (F1 fix) ──────────────────────────
test('_applyBloom calls setBloomPass/setBloomW/setBloomH/setComposer with mock modules', () => {
// Mutation scenarios (all 4 must go RED when severed individually):
// sever setBloomPass(bp) → getBloomPass() stays null → RED
// sever setBloomW(w) → getBloomW() stays 0 → RED
// sever setBloomH(h) → getBloomH() stays 0 → RED
// sever setComposer(comp)→ getComposer() stays null → RED
//
// F1 Toby fix: _applyBloom is named at factory scope and in the return set,
// so the harness calls it directly with mock [EC, RP, UB, OP] — no import() needed.
const di = makeDi();
di._state.T = {
WebGLRenderTarget: function(w, h, opts) { return { _w: w, _h: h }; },
HalfFloatType: 1,
Vector2: function(w, h) { return { w, h }; },
};
di._state.ren = { isRenderer: true };
di._state.scene = { isScene: true };
di._state.cam = { isCamera: true };
const { _applyBloom } = loadFxModule(di);
// Mock module objects matching the destructure [EC, RP, UB, OP].
const fakeComp = { addPass() {}, setSize() {} };
const fakePass = { isBloomPass: true };
const mods = [
{ EffectComposer: function(ren, rt) { return fakeComp; } },
{ RenderPass: function(scene, cam) { return {}; } },
{ UnrealBloomPass: function(v2, s, r, t) { return fakePass; } },
{ OutputPass: function() { return {}; } },
];
_applyBloom(mods);
assert.equal(di.getComposer(), fakeComp,
'setComposer must be called: if severed, getComposer() stays null → bloom never activates');
assert.equal(di.getBloomPass(), fakePass,
'setBloomPass must be called: if severed, pass ref lost → resize/tuning broken');
assert.ok(di.getBloomW() > 0,
'setBloomW must be called with positive width');
assert.ok(di.getBloomH() > 0,
'setBloomH must be called with positive height');
});
// ── 10. _applyCinematic discriminating test (Creed gap fix) ──────────────────
test('_applyCinematic sets light intensities per _cinematic flag (both paths)', () => {
// Mutation that goes RED: `return` inserted at _applyCinematic entry
// → intensities never change → all 4 assertions fail → RED.
const di = makeDi();
const ambLight = { intensity: 0 };
const dirLight = { intensity: 0 };
di._state.ambLight = ambLight;
di._state.dirLight = dirLight;
const { _applyCinematic } = loadFxModule(di);
// Cinematic ON: darken ambient, strengthen key light.
di._state._cinematic = true;
_applyCinematic();
assert.equal(ambLight.intensity, 0.45,
'cinematic=true: ambLight.intensity must be 0.45 (darken for emissive pop)');
assert.equal(dirLight.intensity, 1.15,
'cinematic=true: dirLight.intensity must be 1.15 (stronger key)');
// Cinematic OFF: standard balanced lighting.
di._state._cinematic = false;
_applyCinematic();
assert.equal(ambLight.intensity, 0.85,
'cinematic=false: ambLight.intensity must be 0.85 (standard ambient)');
assert.equal(dirLight.intensity, 0.8,
'cinematic=false: dirLight.intensity must be 0.8 (standard key)');
});
// ── 11. _h3dHexOrDefault — source-scan fixture + literal-pin (Creed r2 fix) ──
test('_h3dHexOrDefault parses valid hex and falls back to BG_DEFAULTS (source-scan fixture)', () => {
// Source-scan: extract the REAL BG_DEFAULTS.nutColor from screen.js so the
// fixture uses the production default, not an invented value.
//
// Guard assertions fail if BG_DEFAULTS is removed or restructured in screen.js —
// drift becomes loud, not silent.
const scr = screenSrc();
const bgDefMatch = scr.match(/const BG_DEFAULTS\s*=\s*\{[^}]+\}/);
assert.ok(bgDefMatch, 'BG_DEFAULTS object literal must be present in screen.js');
const nutColorMatch = bgDefMatch[0].match(/nutColor:\s*'([^']+)'/);
assert.ok(nutColorMatch, 'BG_DEFAULTS.nutColor key must be extractable from screen.js source');
const PROD_NUT_COLOR = nutColorMatch[1];
// Literal-pin: if production nutColor drifts this assertion fails loudly.
// To update intentionally: change the pinned value below to match the new production value.
assert.equal(PROD_NUT_COLOR, '#f5f3f0',
'BG_DEFAULTS.nutColor in screen.js has changed — update this pin if the change is intentional');
// Build fixture using the real production nutColor (not an invented '#cccccc').
// Mutation that goes RED: return BG_DEFAULTS.nutColor unconditionally
// → valid-hex assertion returns fallback instead of parsed value → RED.
const di = makeDi({ BG_DEFAULTS: { nutColor: PROD_NUT_COLOR } });
const { _h3dHexOrDefault } = loadFxModule(di);
// Valid 6-digit hex with # → parsed integer.
assert.equal(_h3dHexOrDefault('#a1b2c3', null), 0xa1b2c3,
'valid hex string must be parsed to its integer value');
// Hex without # → regex requires #, falls back to BG_DEFAULTS.nutColor.
assert.equal(_h3dHexOrDefault('a1b2c3', null), parseInt(PROD_NUT_COLOR.slice(1), 16),
'hex without # must fall back to BG_DEFAULTS.nutColor (regex requires leading #)');
// Gibberish → BG_DEFAULTS fallback.
assert.equal(_h3dHexOrDefault('not-a-color', null), parseInt(PROD_NUT_COLOR.slice(1), 16),
'invalid string must fall back to BG_DEFAULTS.nutColor');
// Explicit defHex overrides BG_DEFAULTS.
assert.equal(_h3dHexOrDefault('not-a-color', '#ffffff'), 0xffffff,
'invalid string with explicit defHex must use defHex, not BG_DEFAULTS');
});
-178
View File
@@ -1,178 +0,0 @@
// Class-killer for src/geometry.js — h3d-carve-1.
//
// Uses dynamic import() (not the vm source-scan pattern) so Node actually
// evaluates the ES module and its exports are the real runtime values.
// A refactor that renames geoFretX, changes the uniform/logarithmic
// decision, removes slideTrailEnd, or breaks computeBPM's BPM estimate
// would be caught here before any other test.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
test('geoFretX returns 0 for fret 0 in both modes', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
assert.strictEqual(geoFretX(0, true), 0, 'uniform: fret 0 must be 0');
assert.strictEqual(geoFretX(0, false), 0, 'logarithmic: fret 0 must be 0');
});
test('geoFretX uniform spacing is linear — fret N is N × fret 1', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
const step = geoFretX(1, true);
assert.ok(step > 0, 'uniform step must be positive');
assert.ok(Math.abs(geoFretX(5, true) - 5 * step) < 1e-9, 'fret 5 must be 5 × step');
assert.ok(Math.abs(geoFretX(12, true) - 12 * step) < 1e-9, 'fret 12 must be 12 × step');
});
test('geoFretX logarithmic spacing is non-linear — frets compress toward the bridge', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
const d1 = geoFretX(1, false);
const d2 = geoFretX(2, false) - geoFretX(1, false);
const d3 = geoFretX(3, false) - geoFretX(2, false);
assert.ok(d1 > d2, 'fret 1 gap must be wider than fret 2 gap (compression toward bridge)');
assert.ok(d2 > d3, 'fret 2 gap must be wider than fret 3 gap');
});
test('geoFretX uniform and logarithmic agree at fret 24 (total board width)', async () => {
const { geoFretX } = await import(GEOMETRY_JS);
// By construction: _fretXUniStep = _fretXLog(24) / 24, so geoFretX(24, uniform)
// equals geoFretX(24, logarithmic). This is the board-width invariant.
const uniWidth = geoFretX(24, true);
const logWidth = geoFretX(24, false);
assert.ok(Math.abs(uniWidth - logWidth) < 1e-9, 'board width must be identical in both modes');
});
test('dZ converts positive dt to a negative Z delta', async () => {
const { dZ } = await import(GEOMETRY_JS);
assert.ok(dZ(1) < 0, 'positive time delta must produce negative Z (notes travel toward camera)');
assert.ok(dZ(0) === 0, 'zero dt must produce zero dZ');
assert.ok(Math.abs(dZ(2) / dZ(1) - 2) < 1e-9, 'dZ must be linear in dt');
});
test('slideTrailEnd returns null for notes with no slide fields', async () => {
const { slideTrailEnd } = await import(GEOMETRY_JS);
assert.strictEqual(slideTrailEnd({}), null);
assert.strictEqual(slideTrailEnd({ sl: -1 }), null, 'negative sl must be ignored');
});
test('slideTrailEnd prefers sl over slu and marks pitched/unpitched correctly', async () => {
const { slideTrailEnd } = await import(GEOMETRY_JS);
assert.deepStrictEqual(slideTrailEnd({ sl: 7 }), { endFret: 7, unpitched: false });
assert.deepStrictEqual(slideTrailEnd({ slu: 5 }), { endFret: 5, unpitched: true });
assert.deepStrictEqual(slideTrailEnd({ sl: 7, slu: 5 }), { endFret: 7, unpitched: false });
});
test('computeBPM returns 120 for degenerate inputs', async () => {
const { computeBPM } = await import(GEOMETRY_JS);
assert.strictEqual(computeBPM(null, 0), 120);
assert.strictEqual(computeBPM([], 0), 120);
assert.strictEqual(computeBPM([{ time: 0 }], 0), 120, 'single beat has no interval');
});
test('computeBPM estimates 120 BPM from evenly-spaced beats', async () => {
const { computeBPM } = await import(GEOMETRY_JS);
// 120 BPM = 0.5 s per beat
const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time }));
const bpm = computeBPM(beats, 1.0);
assert.ok(Math.abs(bpm - 120) < 0.01, `expected ~120 BPM, got ${bpm}`);
});
// Toby r1 findings: camBaseDistU, camLowFretPullbackU, _makeGaussTex had no
// class-killer tests. Each test below names the concrete mutation it catches.
test('camBaseDistU clamps span to minimum 4 — span=0 gives 77 not 65', async () => {
// Mutation: Math.max(span,4) → span
// camBaseDistU(0) mutant = 65+0*3 = 65 (wrong); original = 65+4*3 = 77
const { camBaseDistU } = await import(GEOMETRY_JS);
assert.strictEqual(camBaseDistU(0), 77, 'span=0: floor=4 so 65+4*3=77, not 65');
assert.strictEqual(camBaseDistU(10), 95, 'span=10: 65+10*3=95');
});
test('camLowFretPullbackU is clamped to zero — high fret gives 0 not negative', async () => {
// Mutation: drop Math.max(0,...) clamp
// camLowFretPullbackU(10) mutant = (5-10)*4 = -20 (wrong); original = 0
const { camLowFretPullbackU } = await import(GEOMETRY_JS);
assert.strictEqual(camLowFretPullbackU(0), 20, 'fret 0: (5-0)*4=20');
assert.strictEqual(camLowFretPullbackU(5), 0, 'fret 5: (5-5)*4=0');
assert.strictEqual(camLowFretPullbackU(10), 0, 'fret 10: clamped to 0, not -20');
});
// ── Cut 1b class-killers ───────────────────────────────────────────────────────
test('RENDER_ORDER_LAYER_STACK has 17 layers with CHORD_FILL first and CHORD_FRET_LABEL last', async () => {
const { RENDER_ORDER_LAYER_STACK } = await import(GEOMETRY_JS);
assert.strictEqual(RENDER_ORDER_LAYER_STACK.length, 17, 'stack must have exactly 17 layers');
assert.strictEqual(RENDER_ORDER_LAYER_STACK[0], 'CHORD_FILL', 'first layer must be CHORD_FILL');
assert.strictEqual(RENDER_ORDER_LAYER_STACK[RENDER_ORDER_LAYER_STACK.length - 1], 'CHORD_FRET_LABEL', 'last layer must be CHORD_FRET_LABEL');
});
test('RENDER_ORDER_LAYER_INDEX maps CHORD_FILL to 0 and NOTE_CORE to 10', async () => {
// Mutation: wrong layer order → NOTE_CORE would not map to 10.
const { RENDER_ORDER_LAYER_INDEX } = await import(GEOMETRY_JS);
assert.strictEqual(RENDER_ORDER_LAYER_INDEX['CHORD_FILL'], 0, 'CHORD_FILL must be index 0 (bottom of stack)');
assert.strictEqual(RENDER_ORDER_LAYER_INDEX['NOTE_CORE'], 10, 'NOTE_CORE must be index 10');
});
test('renderOrderForLayerAtZ applies the far clamp — worldZ=-5 gives 50 not 33', async () => {
// Mutation: remove Math.max(RENDER_ORDER_FAR_CLAMP, ...) clamp.
// K=2.25/300=0.0075; Math.round(700+(-5)/0.0075)=Math.round(33.33)=33; max(50,33)=50.
// Without clamp: 33 + 0/17 ≈ 33. Test pins the clamped value.
const { renderOrderForLayerAtZ } = await import(GEOMETRY_JS);
assert.strictEqual(renderOrderForLayerAtZ(-5, 'CHORD_FILL'), 50, 'far objects must be clamped to RENDER_ORDER_FAR_CLAMP=50');
});
test('renderOrderForLayerAtZ throws for unknown layer names', async () => {
const { renderOrderForLayerAtZ } = await import(GEOMETRY_JS);
assert.throws(() => renderOrderForLayerAtZ(0, 'NONEXISTENT'), /Unknown 3D highway depth layer/);
});
test('_noteKey integer-truncates float times — _noteKey(1.5, 3) is 150003 not 150008', async () => {
// Mutation: drop |0 → (15000.5)*10+3 = 150008.
const { _noteKey } = await import(GEOMETRY_JS);
assert.strictEqual(_noteKey(1.5, 3), 150003, '|0 truncation must give 150003, not float-derived 150008');
assert.strictEqual(_noteKey(0, 0), 0);
});
test('lowerBoundT returns first index where arr[i].t >= t (strict lower bound)', async () => {
// Mutation: < → <= causes lowerBoundT([{t:1},{t:3},{t:5}], 3) → 2 instead of 1.
const { lowerBoundT } = await import(GEOMETRY_JS);
const arr = [{ t: 1 }, { t: 3 }, { t: 5 }];
assert.strictEqual(lowerBoundT(arr, 3), 1, 'strict lower-bound: first index where .t >= 3 is 1 (not 2)');
assert.strictEqual(lowerBoundT(arr, 0), 0, 'value before all: must return 0');
assert.strictEqual(lowerBoundT(arr, 6), 3, 'value after all: must return length');
});
test('hwyFirstRelevantFrettedTime returns null for empty/all-open input', async () => {
const { hwyFirstRelevantFrettedTime } = await import(GEOMETRY_JS);
assert.strictEqual(hwyFirstRelevantFrettedTime([], [], 0, 0.2, 6), null);
});
test('geoFretMid returns -2K sentinel for f<=0, positive for f=1', async () => {
// Mutation: drop f<=0 guard → geoFretMid(0, true) returns (0+0)/2=0, not -0.015.
const { geoFretMid } = await import(GEOMETRY_JS);
const K = 2.25 / 300;
assert.ok(Math.abs(geoFretMid(0, true) - (-2 * K)) < 1e-10, 'f=0 must return -2K sentinel (≈-0.015)');
assert.ok(geoFretMid(1, true) > 0, 'f=1 must return positive X');
// In uniform mode: geoFretX(0,true)=0, geoFretX(1,true)=step, so mid=step/2.
// geoFretMid(2,true) = (step+2step)/2 = 1.5step. Ratio 3 catches wrong f offset.
assert.ok(Math.abs(geoFretMid(2, true) / geoFretMid(1, true) - 3) < 1e-9, 'uniform mid(2)/mid(1) must equal 3');
});
test('_makeGaussTex peak alpha is 255 at the centre pixel', async () => {
// Mutation: default sigma changed to 0 → (u-0.5)/0 = NaN chain → all Uint8Array writes
// become 0 (TypedArray coerces NaN to 0). Test calls without explicit sigma so the
// default is exercised directly — changing the default is what is being guarded.
// Use odd width=3: i=1 gives u=0.5 exactly (d=(u-0.5)/sigma=0, peak=1, alpha=255).
const { _makeGaussTex } = await import(GEOMETRY_JS);
let capturedData;
const ThreeStub = {
DataTexture: class { constructor(d) { capturedData = d; } },
RGBAFormat: 1,
LinearFilter: 2,
};
_makeGaussTex(ThreeStub, 3); // no sigma arg — exercises the default (0.28)
// Pixel i=1: RGBA layout [4,5,6,7]; alpha is at index 7
assert.strictEqual(capturedData[7], 255, 'centre pixel (i=1 of w=3) alpha must be 255 at default sigma');
});
+8 -17
View File
@@ -19,12 +19,6 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-14: sustain trail code moved to note-renderer.js; trail tests
// must now search both files.
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
const _noteRendererSrc = fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
const _rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
test('lean sustain rendering is the default (_leanSus starts true)', () => { test('lean sustain rendering is the default (_leanSus starts true)', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
@@ -36,37 +30,34 @@ test('lean sustain rendering is the default (_leanSus starts true)', () => {
}); });
test('the full-quality look is an opt-out via localStorage h3d_full_sus', () => { test('the full-quality look is an opt-out via localStorage h3d_full_sus', () => {
// h3d-carve-15: lean poll + setLeanSus call now in renderer.js update() const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
assert.match( assert.match(
src, src,
/setLeanSus\s*\(\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]\s*\)/, /_leanSus\s*=\s*localStorage\.getItem\(\s*['"]h3d_full_sus['"]\s*\)\s*!==\s*['"]1['"]/,
"lean must stay on unless localStorage.h3d_full_sus === '1' opts back into the full look", "lean must stay on unless localStorage.h3d_full_sus === '1' opts back into the full look",
); );
}); });
test('exactly one element is gated behind the lean flag, and it is the rail bloom', () => { test('exactly one element is gated behind the lean flag, and it is the rail bloom', () => {
// h3d-carve-15: lean gate moved to renderer.js; getter form getLeanSus() const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// Only the additive rail bloom may hide behind the lean flag. If a future // Only the additive rail bloom may hide behind the lean flag. If a future
// edit re-gates the trail or ribbon outline behind !getLeanSus(), this count // edit re-gates the trail or ribbon outline behind !_leanSus, this count
// climbs above 1 and the test fails — that's the regression guard. // climbs above 1 and the test fails — that's the regression guard.
const gates = src.match(/if\s*\(\s*!getLeanSus\s*\(\s*\)\s*\)/g) || []; const gates = src.match(/if\s*\(\s*!_leanSus\s*\)/g) || [];
assert.equal( assert.equal(
gates.length, gates.length,
1, 1,
'expected exactly one `if (!getLeanSus())` gate (the rail bloom); the outline must stay ungated', 'expected exactly one `if (!_leanSus)` gate (the rail bloom); the outline must stay ungated',
); );
assert.match( assert.match(
src, src,
/if\s*\(\s*!getLeanSus\s*\(\s*\)\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/, /if\s*\(\s*!_leanSus\s*\)\s*\{[\s\S]{0,200}?pSusRailBloom\.get\(\)/,
'the single lean gate must be the one that wraps pSusRailBloom.get()', 'the single lean gate must be the one that wraps pSusRailBloom.get()',
); );
}); });
test('the trail + ribbon outline always draw and use the hit/miss-aware material', () => { test('the trail + ribbon outline always draw and use the hit/miss-aware material', () => {
// h3d-carve-14: sustain trail body now in note-renderer.js const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteRendererSrc;
// Outline material is hit/miss aware: miss -> mMissOutline, confirmed hit // Outline material is hit/miss aware: miss -> mMissOutline, confirmed hit
// -> bright, otherwise the default mSusOutline white border. // -> bright, otherwise the default mSusOutline white border.
assert.match( assert.match(
+3 -7
View File
@@ -12,8 +12,6 @@ const ROOT = path.join(__dirname, '..', '..');
const HIGHWAY_JS = path.join(ROOT, 'static', 'highway.js'); const HIGHWAY_JS = path.join(ROOT, 'static', 'highway.js');
const SCREEN_JS = path.join(ROOT, 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(ROOT, 'plugins', 'highway_3d', 'screen.js');
const CLAUDE_MD = path.join(ROOT, 'plugins', 'highway_3d', 'CLAUDE.md'); const CLAUDE_MD = path.join(ROOT, 'plugins', 'highway_3d', 'CLAUDE.md');
// h3d-carve-9: camUpdate (including shoulderOffset + _camX) moved to camera.js.
const CAMERA_JS = path.join(ROOT, 'plugins', 'highway_3d', 'src', 'camera.js');
function src(file) { function src(file) {
return fs.readFileSync(file, 'utf8'); return fs.readFileSync(file, 'utf8');
@@ -71,14 +69,12 @@ test('draw(bundle) handles lefty changes by flipping camera X state and rebuildi
}); });
test('camera shoulder offset follows the cached lefty orientation', () => { test('camera shoulder offset follows the cached lefty orientation', () => {
// h3d-carve-9: camUpdate (shoulderOffset + _camX) moved to src/camera.js;
// _leftyCached is DI-rewired to getLeftyCached() direct call.
assert.match( assert.match(
src(CAMERA_JS), src(SCREEN_JS),
// The shoulder offset now feeds the base _camX (which the opt-in // The shoulder offset now feeds the base _camX (which the opt-in
// free-camera bridge layers on top of) before cam.position.set (#771). // free-camera bridge layers on top of) before cam.position.set (#771).
/const\s+shoulderOffset\s*=\s*\(\s*getLeftyCached\(\)\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/, /const\s+shoulderOffset\s*=\s*\(\s*_leftyCached\s*\?\s*-1\s*:\s*1\s*\)\s*\*\s*10\s*\*\s*K\s*;[\s\S]*?_camX\s*=\s*curX\s*\+\s*shoulderOffset/,
'camera shoulder offset must flip with getLeftyCached()', 'camera shoulder offset must flip with _leftyCached',
); );
}); });
-361
View File
@@ -1,361 +0,0 @@
// Contract tests for h3d-carve-6: src/materials.js (createMaterialBuilders).
//
// Class-killer tests — each names the mutation that makes it RED.
// Source-scan + vm-sandbox pattern (no canvas, no WebGL lifecycle).
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const MATERIALS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'materials.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function src() { return fs.readFileSync(MATERIALS_JS, 'utf8'); }
function screenSrc() { return fs.readFileSync(SCREEN_JS, 'utf8'); }
// Strip block and line comments from JS source for identifier-presence checks.
function stripComments(s) {
return s
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/[^\n]*/g, ''); // line comments
}
// ── vm sandbox helpers ──────────────────────────────────────────────────────
// Evaluate materials.js in a sandbox and return the createMaterialBuilders export.
// Three.js is stubbed; canvas ops are no-ops.
function loadFactory() {
const raw = src();
// materials.js uses `export function` — strip the `export` keyword for vm.
const code = raw.replace(/^export\s+/m, '');
const sandbox = {
document: {
createElement: () => ({
getContext: () => ({
font: '', textAlign: '', textBaseline: '',
clearRect() {}, beginPath() {}, moveTo() {}, lineTo() {},
closePath() {}, fill() {}, stroke() {}, fillText() {},
strokeText() {}, save() {}, restore() {}, translate() {},
ellipse() {}, arc() {}, createRadialGradient: () => ({
addColorStop() {},
}),
measureText: () => ({
width: 10,
actualBoundingBoxLeft: 0, actualBoundingBoxRight: 10,
actualBoundingBoxAscent: 8, actualBoundingBoxDescent: 2,
}),
getImageData: (x, y, w, h) => ({ data: new Uint8Array(w * h * 4) }),
fillStyle: '', strokeStyle: '', lineWidth: 0,
lineJoin: '', lineCap: '', shadowColor: '',
shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
miterLimit: 0, globalCompositeOperation: '',
}),
width: 0, height: 0,
}),
},
Math,
Number,
Map,
Set,
String,
Object,
Array,
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: MATERIALS_JS });
return sandbox.createMaterialBuilders;
}
// Build a minimal DI bundle for testing factory internals.
function makeDI(overrides = {}) {
let txtCache = {};
const techMatCache = new Map();
const techMeshMatClones = new Set();
const T = {
SpriteMaterial: class { constructor(o) { Object.assign(this, o); this.map = o.map; this.userData = {}; } clone() { const c = new T.SpriteMaterial(this); return c; } dispose() {} },
MeshBasicMaterial: class { constructor(o) { Object.assign(this, o); this.userData = {}; } clone() { return new T.MeshBasicMaterial(this); } dispose() {} },
CanvasTexture: class { constructor(c) { this._c = c; } dispose() {} },
Color: class { constructor(v) { this.r = 1; this.g = 1; this.b = 1; } getHexString() { return 'ffffff'; } },
DoubleSide: 2,
};
return {
getT: () => T,
getTxtCache: () => txtCache,
techMatCache,
techMeshMatClones,
_resetCache: () => { txtCache = {}; },
...overrides,
};
}
// ── 1. Module exports createMaterialBuilders ────────────────────────────────
test('src/materials.js exports createMaterialBuilders', () => {
// Mutation: rename to createMaterials → RED.
assert.match(src(), /export\s+function\s+createMaterialBuilders\s*\(/, 'must export createMaterialBuilders');
});
// ── 2. Return set covers all expected symbols ───────────────────────────────
test('createMaterialBuilders returns all required symbols', () => {
// Mutation: remove `pool` from return {...} → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const result = createMaterialBuilders(di);
const EXPECTED = [
'txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat',
'palmMuteXSpriteMat', 'fretHandMuteXSpriteMat', 'muteXMat',
'triMat', 'bendChevronMat', 'darkenHex', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat', 'pool',
];
for (const sym of EXPECTED) {
assert.ok(sym in result, `createMaterialBuilders must return ${sym}`);
}
});
// ── 3. Stranded-caller: every returned symbol in screen.js destructure ───────
test('every symbol returned by createMaterialBuilders is in the screen.js destructure', () => {
// Mutation: add _newHelper to return{} but not the screen.js destructure → leaked=['_newHelper'] → RED.
const matSrc = stripComments(src());
const scr = screenSrc();
// Extract the module-level return block — anchored by the first symbol
// (txtMat) so pool's inner return { get, reset, warm } can't shadow it.
// Mutation: remove txtMat from the return → anchor fails → RED.
const retMatch = matSrc.match(/return\s*\{\s*\n\s*(txtMat\s*,[\s\S]+?)\n\s*\};/);
assert.ok(retMatch, 'createMaterialBuilders must end with return { txtMat, ... }');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Extract destructured names from the screen.js tombstone.
const dsMatch = scr.match(/const\s*\{([^}]+)\}\s*=\s*createMaterialBuilders\s*\(/);
assert.ok(dsMatch, 'screen.js must have createMaterialBuilders destructure');
const destructured = new Set(
dsMatch[1].split(',').map(s => s.trim().split(/\s+/).pop()).filter(Boolean)
);
const leaked = [...returned].filter(sym => !destructured.has(sym));
assert.deepStrictEqual(leaked, [],
'createMaterialBuilders returns symbols not in screen.js destructure: ' + leaked.join(', '));
});
// ── 4. Stale-private guard: factory-private symbols not bare in screen.js ──────
test('factory-private symbols in materials.js do not appear bare in screen.js', () => {
// Mutation: add bare TXT_STYLES to screen.js body → violations=['TXT_STYLES'] → RED.
// Kills the whole class: any factory-depth-1 const/let NOT in the return set
// must not leak into screen.js. Catches TXT_STYLES, _pmXSpriteMat, _fhXSpriteMat
// and any future factory-private additions automatically.
const matSrc = stripComments(src());
const scrRaw = screenSrc();
// Extract the returned symbol set (reuse test 3's anchor).
const retMatch = matSrc.match(/return\s*\{\s*\n\s*(txtMat\s*,[\s\S]+?)\n\s*\};/);
assert.ok(retMatch, 'return block must be present');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Factory-depth-1 const/let declarations: exactly 4-space indent.
// These are factory-private vars (_pmXSpriteMat, _fhXSpriteMat, TXT_STYLES …).
// Depth-2 locals (const T = getT() etc.) have 8+ spaces — excluded by anchor.
const privateSyms = [];
for (const m of matSrc.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
const sym = m[1];
if (!returned.has(sym)) privateSyms.push(sym);
}
assert.ok(privateSyms.length > 0, 'factory must have at least one private depth-1 declaration');
// Strip screen.js of import lines, comments, and the destructure line.
let scr = scrRaw.replace(/^import\s+.*\n/gm, '');
scr = stripComments(scr);
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createMaterialBuilders\s*\([^)]*\)\s*;/, '');
const violations = privateSyms.filter(sym =>
new RegExp('\\b' + sym + '\\b').test(scr)
);
assert.deepStrictEqual(violations, [],
'screen.js must not reference factory-private materials.js symbols: ' + violations.join(', '));
});
// ── 5. DI: T is accessed via getT() at call time, not factory construction ───
test('material builder functions call getT() at call time', () => {
// Mutation: top-level `const T = getT()` at factory construction → RED.
// Each function must call getT() inside its own body.
const s = src();
// Must NOT have `const T = getT()` at the top level of the factory
// (outside any function body). Check that it's scoped inside function bodies.
assert.doesNotMatch(
s,
/createMaterialBuilders\s*\([^)]*\)\s*\{[^}]*const T = getT\(\)/,
'T must not be captured at factory construction — only inside function bodies'
);
// Each T-using function must contain getT() in its body.
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat',
'triMat', 'bendChevronMat', 'slideArrowMat',
'_meshMatForGhostFretDigit', '_spriteMat2MeshMat']) {
const fnIdx = s.indexOf(`function ${fn}(`);
assert.ok(fnIdx !== -1, `${fn} must exist in materials.js`);
// Find the body of this function (brace-balanced).
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /const T = getT\(\)/, `${fn} must call getT() inside its body`);
}
});
// ── 6. DI: txtCache is accessed via getTxtCache() inside each function ────────
test('txtCache-using functions access cache via getTxtCache()', () => {
// Mutation: use bare `txtCache[k]` instead → RED (and also a runtime bug).
const s = stripComments(src());
// The module code must never reference a bare `txtCache` identifier.
assert.doesNotMatch(s, /\btxtCache\b/, 'materials.js code must not reference bare txtCache — use getTxtCache()');
// Each cache-using function must call getTxtCache().
for (const fn of ['txtMat', 'pinchHarmonicMat', 'naturalHarmonicMat', 'muteXMat']) {
assert.ok(s.includes(`function ${fn}(`), `${fn} must exist`);
const fnIdx = s.indexOf(`function ${fn}(`);
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.match(body, /getTxtCache\(\)/, `${fn} must call getTxtCache() inside its body`);
}
});
// ── 7. DI: techMatCache param used (not bare _techMatCache) ──────────────────
test('triMat/bendChevronMat/slideArrowMat use techMatCache DI param', () => {
// Mutation: use `_techMatCache.get(key)` → RED (and runtime ReferenceError).
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMatCache\b/, 'materials.js code must not reference _techMatCache — use DI param techMatCache');
});
// ── 8. DI: techMeshMatClones param used (not bare _techMeshMatClones) ─────────
test('_spriteMat2MeshMat uses techMeshMatClones DI param', () => {
// Mutation: use `_techMeshMatClones.add(clone)` → RED.
const s = stripComments(src());
assert.doesNotMatch(s, /\b_techMeshMatClones\b/, 'materials.js code must not reference _techMeshMatClones — use DI param');
});
// ── 9. TXT_STYLES literal pin ────────────────────────────────────────────────
test('TXT_STYLES presets match known-good values', () => {
// Mutation: change technique.srcH from 128 to 256 → RED.
const s = src();
// fretRow / noteFret / ghostFret — srcH 256, strokeW 18
for (const key of ['fretRow', 'noteFret', 'ghostFret']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*256'), `${key}.srcH must be 256`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*18'), `${key}.strokeW must be 18`);
}
// All three large presets share this stroke color.
assert.ok(s.includes("stroke: '#0a1018'"), "fretRow/noteFret/ghostFret stroke must be '#0a1018'");
// chord / section / technique / open — srcH 128, strokeW 6
for (const key of ['chord', 'section', 'technique', 'open']) {
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}srcH:\\s*128'), `${key}.srcH must be 128`);
assert.match(s, new RegExp(key + '[\\s\\S]{0,400}strokeW:\\s*6'), `${key}.strokeW must be 6`);
}
});
// ── 10. darkenHex is pure (no T dependency) ──────────────────────────────────
test('darkenHex has no getT() call', () => {
// Mutation: add getT() call → RED (no T needed for a pure bit-twiddler).
const s = src();
const fnIdx = s.indexOf('function darkenHex(');
assert.ok(fnIdx !== -1, 'darkenHex must exist');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'darkenHex must not call getT() — it is a pure bit-twiddler');
});
// ── 11. pool has no getT() call ──────────────────────────────────────────────
test('pool has no getT() call', () => {
// Mutation: add getT() call → RED (pool creates no Three.js objects).
const s = src();
const fnIdx = s.indexOf('function pool(parent, mk)');
assert.ok(fnIdx !== -1, 'pool must exist in materials.js');
const openBrace = s.indexOf('{', fnIdx);
let depth = 1, i = openBrace + 1;
while (i < s.length && depth > 0) {
if (s[i] === '{') depth++;
else if (s[i] === '}') depth--;
i++;
}
const body = s.slice(openBrace, i);
assert.doesNotMatch(body, /getT\(\)/, 'pool must not call getT() — it is a pure container factory');
});
// ── 12. screen.js tombstone is present ───────────────────────────────────────
test('screen.js has the h3d-carve-6 tombstone comment', () => {
// Mutation: delete the tombstone block → RED.
assert.match(screenSrc(), /h3d-carve-6: material builders/, 'tombstone comment must be present');
assert.match(screenSrc(), /const _techMatCache = new Map\(\)/, '_techMatCache must be hoisted to screen.js factory scope');
});
// ── 13. _techMatCache NOT declared in materials.js code ───────────────────────
test('_techMatCache is not declared in materials.js code', () => {
// Mutation: move const _techMatCache = new Map() into materials.js → RED
// (teardown accesses it directly via the factory-scope const).
assert.doesNotMatch(stripComments(src()), /const _techMatCache\s*=/,
'_techMatCache must not be declared in materials.js — it stays in screen.js factory scope for teardown');
});
// ── 14. txtMat caches and returns a SpriteMaterial ───────────────────────────
test('txtMat creates and caches a SpriteMaterial on cache miss', () => {
// Mutation: remove `cache[k] = mat` → txtMat allocates a new material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { txtMat } = createMaterialBuilders(di);
const m1 = txtMat('5', '#ff0000', false, 'noteFret');
assert.ok(m1, 'txtMat must return a material');
const m2 = txtMat('5', '#ff0000', false, 'noteFret');
assert.strictEqual(m1, m2, 'txtMat must return the same instance on cache hit');
const m3 = txtMat('5', '#00ff00', false, 'noteFret');
assert.notStrictEqual(m1, m3, 'different color → different material');
});
// ── 15. triMat cache uses techMatCache (DI param), not a local Map ───────────
test('triMat stores results in techMatCache and returns cached entry', () => {
// Mutation: return a fresh material every call → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { triMat } = createMaterialBuilders(di);
assert.strictEqual(di.techMatCache.size, 0, 'techMatCache starts empty');
const m1 = triMat(true, 0xff0000);
assert.strictEqual(di.techMatCache.size, 1, 'triMat must populate techMatCache');
const m2 = triMat(true, 0xff0000);
assert.strictEqual(m1, m2, 'triMat cache hit must return same object');
});
// ── 16. pool warm() pre-allocates and warm() is idempotent ───────────────────
test('pool.warm pre-allocates up to cap and is idempotent past cap', () => {
// Mutation: remove while-loop in warm() → warm() allocates nothing → RED.
const createMaterialBuilders = loadFactory();
const di = makeDI();
const { pool } = createMaterialBuilders(di);
const parent = { add() {} };
let mkCount = 0;
const p = pool(parent, () => { mkCount++; return { visible: true, center: null }; });
p.warm(5);
assert.strictEqual(mkCount, 5, 'warm(5) must pre-allocate 5 objects');
p.warm(3); // below current length — must be idempotent
assert.strictEqual(mkCount, 5, 'warm(3) after warm(5) must not allocate more');
p.warm(8);
assert.strictEqual(mkCount, 8, 'warm(8) after warm(5) must allocate 3 more');
});
-474
View File
@@ -1,474 +0,0 @@
// 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`);
}
});
-315
View File
@@ -1,315 +0,0 @@
// Source + behavioural guards for h3d-carve-7: O-section (lyrics + HUD overlay)
// extracted to src/overlay.js.
//
// Class-killers guaranteed:
// 1. Module exports createOverlay (source)
// 2. createOverlay return set covers all 5 required symbols (source)
// 3. Stranded-caller: every returned symbol appears in screen.js destructure (source)
// 4. Private-guard: factory-depth-1 privates not bare in screen.js (source)
// 5. Moved constants absent from screen.js (source — deletion check)
// 6. _diagRenderCache ref-identity: teardown .clear() reaches the same Map passed to
// createOverlay (behavioural — mutation: new Map() breaks it → RED)
// 7. drawSectionHud returns 0 when no sections (behavioural)
// 8. drawLyrics returns a number (behavioural)
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const OVERLAY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'overlay.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
function stripComments(s) {
return s.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
}
function src() {
return fs.readFileSync(OVERLAY_JS, 'utf8');
}
function screenSrc() {
return fs.readFileSync(SCREEN_JS, 'utf8');
}
// ── 1. Module exports createOverlay ─────────────────────────────────────────
test('overlay.js exports createOverlay', () => {
assert.match(src(), /export\s+function\s+createOverlay\s*\(/);
});
// ── 2. Return set covers all 5 required symbols ──────────────────────────────
test('createOverlay returns all 5 required symbols', () => {
const stripped = stripComments(src());
const REQUIRED = ['drawChordDiagram', '_drawDiagramCached', 'drawSectionHud', 'drawToneHud', 'drawLyrics'];
// Match the factory-level return block (4-space indent inside createOverlay).
// Inner function returns like longestConsecutiveRun's are at 8+ spaces.
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of REQUIRED) {
assert.ok(returned.includes(sym), `return set must include ${sym}`);
}
});
// ── 3. Stranded-caller: returned ⊆ screen.js destructure ────────────────────
test('every createOverlay returned symbol appears in screen.js destructure', () => {
// Mutation: remove _drawDiagramCached from screen.js destructure → missing → RED.
const stripped = stripComments(src());
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = retMatch[1].split(',').map(s => s.trim()).filter(Boolean);
const scrRaw = screenSrc();
const destrMatch = scrRaw.match(/const\s*\{([^}]+)\}\s*=\s*createOverlay\s*\(/);
assert.ok(destrMatch, 'screen.js must have a createOverlay destructure');
const destructured = destrMatch[1].split(',').map(s => s.trim()).filter(Boolean);
for (const sym of returned) {
assert.ok(destructured.includes(sym),
`returned symbol '${sym}' must appear in screen.js createOverlay destructure`);
}
});
// ── 4. Private-guard: factory-depth-1 privates not bare in screen.js ─────────
test('factory-private symbols in overlay.js do not appear bare in screen.js', () => {
// Mutation: add bare _DIAG_CACHE_MAX to screen.js → violations → RED.
const stripped = stripComments(src());
// Collect returned symbols (factory-level return, 4-space indent).
const retMatch = stripped.match(/\n {4}return\s*\{\s*\n([\s\S]+?)\n {4}\};/);
assert.ok(retMatch, 'factory-level return block must be present');
const returned = new Set(
retMatch[1].split(',').map(s => s.trim()).filter(Boolean)
);
// Factory-depth-1 const/let: exactly 4-space indent inside createOverlay body.
const privateSyms = [];
for (const m of stripped.matchAll(/^ {4}(?:const|let)\s+(\w+)/gm)) {
const sym = m[1];
if (!returned.has(sym)) privateSyms.push(sym);
}
assert.ok(privateSyms.length > 0, 'factory must have at least one private depth-1 declaration');
let scr = screenSrc().replace(/^import\s+.*\n/gm, '');
scr = stripComments(scr);
scr = scr.replace(/const\s*\{[^}]+\}\s*=\s*createOverlay\s*\([^)]*\)\s*;/, '');
const violations = privateSyms.filter(sym =>
new RegExp('\\b' + sym + '\\b').test(scr)
);
assert.deepStrictEqual(violations, [],
'screen.js must not reference factory-private overlay.js symbols: ' + violations.join(', '));
});
// ── 5. Moved constants absent from screen.js ─────────────────────────────────
test('DIAG_SIZE_MIN, DIAG_SIZE_MAX, DIAG_CELL_MAX, _DIAG_CACHE_MAX absent from screen.js', () => {
// Mutation: add const DIAG_SIZE_MIN = 0.08 back to screen.js → RED.
const scr = stripComments(screenSrc());
for (const sym of ['DIAG_SIZE_MIN', 'DIAG_SIZE_MAX', 'DIAG_CELL_MAX', '_DIAG_CACHE_MAX']) {
assert.doesNotMatch(scr, new RegExp('const\\s+' + sym + '\\b'),
`const ${sym} must not appear in screen.js (it moved to overlay.js)`);
}
});
// ── 6. Ref-identity class-killer: source-scan ────────────────────────────────
test('screen.js passes _diagRenderCache (not a new Map) to createOverlay', () => {
// This is the primary ref-severing class-killer god requested.
// Mutation: change createOverlay({ diagRenderCache: _diagRenderCache })
// to createOverlay({ diagRenderCache: new Map() })
// → teardown .clear() on screen.js's _diagRenderCache no longer reaches the
// overlay cache → cache leaks → this test goes RED.
const scr = stripComments(screenSrc());
assert.match(
scr,
/createOverlay\s*\(\s*\{\s*diagRenderCache\s*:\s*_diagRenderCache\s*\}\s*\)/,
'screen.js must pass _diagRenderCache (not a new Map or other value) as diagRenderCache to createOverlay',
);
});
// ── 6b8: Behavioural tests in a vm sandbox ──────────────────────────────────
// createOverlay needs a diagRenderCache Map (stable ref). The functions under
// test do canvas 2D drawing; we stub ctx with the minimal surface they call.
function makeCtx() {
return {
save() {}, restore() {}, beginPath() {}, fill() {}, stroke() {},
moveTo() {}, lineTo() {}, arc() {}, roundRect() {}, closePath() {},
fillText() {}, strokeText() {}, quadraticCurveTo() {},
fillRect() {}, strokeRect() {}, drawImage() {},
measureText(t) { return { width: t.length * 7 }; },
fillStyle: '', strokeStyle: '', lineWidth: 1,
globalAlpha: 1, font: '', textAlign: '', textBaseline: '',
shadowColor: '', shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0,
};
}
function loadModule(diagRenderCache) {
const raw = fs.readFileSync(OVERLAY_JS, 'utf8');
// Strip the ES module export keyword so the script runs in a vm CommonJS-style.
// Use /m flag so ^ matches line starts (file begins with a comment block).
const code = raw.replace(/^export\s+function\s+createOverlay/m, 'function createOverlay');
const sandbox = {
OffscreenCanvas: class { constructor(w, h) { this.width=w; this.height=h; }
getContext() { return makeCtx(); } },
document: {
createElement() {
return { width: 0, height: 0, getContext() { return makeCtx(); } };
}
},
console,
__exports: {},
};
vm.createContext(sandbox);
vm.runInContext(code + '\n__exports.createOverlay = createOverlay;', sandbox);
return sandbox.__exports.createOverlay({ diagRenderCache });
}
// ── 6. _diagRenderCache ref-identity ────────────────────────────────────────
test('diagRenderCache passed to createOverlay is the same Map reached by teardown .clear()', () => {
// This is the class-killer god requested.
//
// Mutation: in screen.js, change
// createOverlay({ diagRenderCache: _diagRenderCache })
// to
// createOverlay({ diagRenderCache: new Map() })
// → the overlay populates its own Map, but screen.js teardown clears _diagRenderCache
// (a different object) → overlay cache leaks → both Map sizes diverge → RED.
//
// Here we verify the ref is the same Map by populating a sentinel key via
// _drawDiagramCached (which writes to diagRenderCache) and then confirming
// the original Map reference sees the write.
const sharedMap = new Map();
const { _drawDiagramCached } = loadModule(sharedMap);
const ctx = makeCtx();
// entranceT < 1 bypasses cache; entranceT = 1.0 triggers the cache write.
// Set opacity=0 to short-circuit before the cache write → use opacity=1.
_drawDiagramCached(ctx, {
name: 'Am', frets: [0, 0, 2, 2, 1, 0], nStr: 6,
inverted: false, sizeSlider: 0.5, position: 'tl',
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
lyricsBottom: 0, stackOffset: 0,
});
// The overlay must have written to sharedMap (the same ref we passed in).
assert.ok(sharedMap.size > 0,
'overlay must write to the diagRenderCache Map reference passed in via DI; ' +
'if size=0 the ref was severed (createOverlay got a different Map)');
// Simulating teardown: clear the same Map as screen.js would.
sharedMap.clear();
assert.equal(sharedMap.size, 0, 'Map cleared by teardown must now be empty');
// A second call re-populates the shared Map (not a separate internal one).
_drawDiagramCached(ctx, {
name: 'G', frets: [3, 2, 0, 0, 3, 3], nStr: 6,
inverted: false, sizeSlider: 0.5, position: 'tl',
canvasW: 600, canvasH: 400, opacity: 1, entranceT: 1.0,
lyricsBottom: 0, stackOffset: 0,
});
assert.ok(sharedMap.size > 0, 'cache repopulated via the same shared Map reference');
});
// ── 7. drawSectionHud returns 0 for no sections ───────────────────────────────
test('drawSectionHud returns 0 when sections array is empty', () => {
const { drawSectionHud } = loadModule(new Map());
const ctx = makeCtx();
const result = drawSectionHud(ctx, {
sections: [], currentTime: 10,
canvasW: 800, canvasH: 600,
});
assert.equal(result, 0);
});
// ── 8. drawLyrics returns a number ───────────────────────────────────────────
test('drawLyrics returns a finite number', () => {
const { drawLyrics } = loadModule(new Map());
const ctx = makeCtx();
const lyrics = [
{ w: 'Hel-', t: 0, d: 0.3 }, { w: 'lo+', t: 0.3, d: 0.3 },
{ w: 'World', t: 0.6, d: 0.4 },
];
const result = drawLyrics(lyrics, 0.15, ctx, 800, 600);
assert.ok(typeof result === 'number' && isFinite(result),
'drawLyrics must return a finite number (bottom Y of lyrics banner)');
});
// Recording ctx — captures fillText/roundRect/fill for discriminating render assertions.
// Only used by tests 9 and 10 below; makeCtx() remains the non-recording stub.
function makeRecordingCtx() {
const calls = [];
const base = makeCtx();
return new Proxy(base, {
get(t, prop) {
if (prop === '_calls') return calls;
if (prop === 'fillText') {
return function(text, x, y) { calls.push({ method: 'fillText', text }); };
}
if (prop === 'roundRect') {
return function(...args) { calls.push({ method: 'roundRect' }); };
}
if (prop === 'fill') {
return function() { calls.push({ method: 'fill' }); };
}
return typeof t[prop] === 'function' ? t[prop].bind(t) : t[prop];
},
set(t, prop, val) { t[prop] = val; return true; },
});
}
// ── 9. drawToneHud real rendering path — discriminating (class-killer) ────────
test('drawToneHud renders tone name and HUD card when tone state is non-empty', () => {
// Mutation that must go RED: `return 0` inserted at overlay.js:631 (Creed's exact
// injection, top of drawToneHud body after the destructure).
// With that mutation: _calls stays empty, boxH=0 → all three assertions fail → RED.
//
// Scenario: t=5, toneBase='Clean', one upcoming change at t=10 ('Lead').
const { drawToneHud } = loadModule(new Map());
const ctx = makeRecordingCtx();
const boxH = drawToneHud(ctx, {
toneBase: 'Clean',
toneChanges: [{ t: 10, name: 'Lead' }],
currentTime: 5,
canvasW: 800, canvasH: 600,
position: 'tl', sizeSlider: 0.5,
});
assert.ok(boxH > 0,
'drawToneHud must return boxH > 0 with non-empty tone state (current=Clean, next=Lead)');
const texts = ctx._calls.filter(c => c.method === 'fillText').map(c => c.text);
assert.ok(texts.some(t => t.includes('Clean')),
'drawToneHud must fillText the current tone name; got: ' + JSON.stringify(texts));
assert.ok(texts.some(t => t.includes('Lead')),
'drawToneHud must fillText the next tone name; got: ' + JSON.stringify(texts));
assert.ok(ctx._calls.some(c => c.method === 'fill'),
'drawToneHud must call ctx.fill() (background card) with non-empty state');
});
// ── 10. drawSectionHud real rendering path — discriminating (class-killer) ────
test('drawSectionHud renders section name and HUD card when sections are non-empty', () => {
// Mutation that must go RED: `return 0` inserted after the early-exit guard
// (after the `if (!sections || !sections.length) return 0;` line), gutting the
// non-empty rendering branch of drawSectionHud.
// With that mutation: _calls stays empty, boxH=0 → all three assertions fail → RED.
//
// Scenario: two sections, currentTime in the first one.
const { drawSectionHud } = loadModule(new Map());
const ctx = makeRecordingCtx();
const boxH = drawSectionHud(ctx, {
sections: [{ time: 0, name: 'Intro' }, { time: 10, name: 'Verse' }],
currentTime: 5,
canvasW: 800, canvasH: 600,
position: 'tr', sizeSlider: 0.5,
});
assert.ok(boxH > 0,
'drawSectionHud must return boxH > 0 with non-empty sections (cur=Intro, next=Verse)');
const texts = ctx._calls.filter(c => c.method === 'fillText').map(c => c.text);
assert.ok(texts.some(t => t.includes('Intro')),
'drawSectionHud must fillText the current section name; got: ' + JSON.stringify(texts));
assert.ok(texts.some(t => t.includes('Verse')),
'drawSectionHud must fillText the next section name; got: ' + JSON.stringify(texts));
assert.ok(ctx._calls.some(c => c.method === 'fill'),
'drawSectionHud must call ctx.fill() (background card) with non-empty state');
});
+1 -86
View File
@@ -28,11 +28,7 @@ function loadHighway3dStatics() {
1, 1,
'expected exactly one factory-registration anchor in screen.js', 'expected exactly one factory-registration anchor in screen.js',
); );
// Since h3d-carve-1 screen.js starts with ES module import statements. const instrumented = src.replace(
// vm.runInContext does not support static import — strip all leading import
// lines and provide stub implementations of the exports in the sandbox.
const stripped = src.replace(/^(import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"];\s*\/\/[^\n]*\n)+/m, '');
const instrumented = stripped.replace(
ANCHOR, ANCHOR,
`${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS };`, `${ANCHOR}\n window.__h3dTestExports = { BG_DEFAULTS };`,
); );
@@ -54,87 +50,6 @@ function loadHighway3dStatics() {
register() {}, register() {},
}, },
}, },
// Geometry stubs — panel-controls test only reads factory statics;
// it never invokes the render path where these are called (h3d-carve-1b).
geoFretX: (f, _uniform) => f * 0.1,
dZ: dt => -dt,
slideTrailEnd: () => null,
camBaseDistU: span => span,
camLowFretPullbackU: () => 0,
computeBPM: () => 120,
_makeGaussTex: () => ({}),
RENDER_ORDER_LAYER_STACK: Object.freeze([]),
RENDER_ORDER_LAYER_INDEX: Object.freeze(Object.create(null)),
RENDER_ORDER_AT_Z_ZERO: 700,
RENDER_ORDER_FAR_CLAMP: 50,
renderOrderForLayerAtZ: () => 0,
_noteKey: () => 0,
lowerBoundT: () => 0,
hwyFirstRelevantFrettedTime: () => null,
geoFretMid: (f, _uniform) => f * 0.1,
// h3d-carve-2: T and loadThree moved to src/three-loader.js.
T: null,
loadThree: () => Promise.resolve(),
// h3d-carve-3: color/tuning/splitscreen utils moved to src/utils.js.
_h3dHexToInt: () => null,
_clampByteI: n => n,
_darkenInt: (hex) => hex,
_lightenInt: (hex) => hex,
resolveStringCount: () => 6,
_NOTE_NAMES_SHARP: ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'],
_BASE_OPEN_MIDI_BASS4: Object.freeze([28, 33, 38, 43]),
_BASE_OPEN_MIDI_BASS5: Object.freeze([23, 28, 33, 38, 43]),
_BASE_OPEN_MIDI_GUITAR6: Object.freeze([40, 45, 50, 55, 59, 64]),
_BASE_OPEN_MIDI_GUITAR7: Object.freeze([35, 40, 45, 50, 55, 59, 64]),
_BASE_OPEN_MIDI_GUITAR8: Object.freeze([28, 35, 40, 45, 50, 55, 59, 64]),
_baseOpenStringMidis: () => [40, 45, 50, 55, 59, 64],
_midiToPitchLabel: () => 'A4',
_openStringPitchLabelsForTuning: () => [],
_ssActive: () => false,
_ssIsCanvasFocused: () => true,
// h3d-carve-4: Butterchurn panel moved to src/bc-panel.js.
_bcIsDesktop: () => false,
_bcCreateController: (wrap, sizeProvider, audioProvider) => ({
applySettings() {}, dead() { return false; }, ready() { return false; },
boundAnalyser() { return null; }, audioCtx() { return null; },
reconnectAudio() { return false; }, chart() {}, tint() {}, render() {},
resize() {}, destroy() {},
}),
// h3d-carve-5: player-chrome bg-control moved to src/bg-control.js.
createBgControl: () => ({
_pcAcquire() {}, _pcRelease() {},
}),
// h3d-carve-6: material builders moved to src/materials.js.
createMaterialBuilders: () => ({
txtMat() {}, pinchHarmonicMat() {}, naturalHarmonicMat() {},
palmMuteXSpriteMat() {}, fretHandMuteXSpriteMat() {}, muteXMat() {},
triMat() {}, bendChevronMat() {}, darkenHex: (hex) => hex, slideArrowMat() {},
_meshMatForGhostFretDigit() {}, _spriteMat2MeshMat() {},
pool: () => ({ get() {}, reset() {}, warm() { return this; } }),
}),
// h3d-carve-7: overlay (lyrics + HUD) moved to src/overlay.js.
createOverlay: () => ({
drawChordDiagram() { return 0; },
_drawDiagramCached() { return 0; },
drawSectionHud() { return 0; },
drawToneHud() { return 0; },
drawLyrics() { return 0; },
}),
// h3d-carve-8: Q-helpers (lighting/FX) moved to src/fx.js.
createFx: () => ({
_h3dHexOrDefault() { return 0; },
_applyCinematic() {},
_timingHex() { return 0x22ff88; },
_sparkBurst() {},
_sparkUpdate() {},
_applyBloom() {},
_bloomEnsure() { return null; },
}),
// h3d-carve-9: W-section (camera lerp) moved to src/camera.js.
createCamera: () => ({
effectiveVfov() { return 70; },
camUpdate() {},
}),
}; };
vm.createContext(sandbox); vm.createContext(sandbox);
vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS }); vm.runInContext(instrumented, sandbox, { filename: SCREEN_JS });
+3 -8
View File
@@ -15,9 +15,6 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
// h3d-carve-6: pool() moved to src/materials.js; warm() call-sites remain in screen.js.
const MATERIALS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'materials.js');
// Brace-balanced extraction so warm() / coercion checks scope to the // Brace-balanced extraction so warm() / coercion checks scope to the
// `function pool(...)` body (matching the helper shape used in // `function pool(...)` body (matching the helper shape used in
@@ -42,8 +39,7 @@ function extractBlock(src, signature) {
} }
test('pool factory exposes warm(cap)', () => { test('pool factory exposes warm(cap)', () => {
// h3d-carve-6: pool() lives in src/materials.js (createMaterialBuilders). const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(MATERIALS_JS, 'utf8');
// The pool() factory's return object must include a `warm(cap)` // The pool() factory's return object must include a `warm(cap)`
// method. Scope the match to the factory body so an unrelated // method. Scope the match to the factory body so an unrelated
// future `warm(cap)` helper elsewhere in the file can't satisfy // future `warm(cap)` helper elsewhere in the file can't satisfy
@@ -53,8 +49,7 @@ test('pool factory exposes warm(cap)', () => {
}); });
test('pool.warm coerces cap to a non-negative integer', () => { test('pool.warm coerces cap to a non-negative integer', () => {
// h3d-carve-6: pool() lives in src/materials.js (createMaterialBuilders). const src = fs.readFileSync(SCREEN_JS, 'utf8');
const src = fs.readFileSync(MATERIALS_JS, 'utf8');
// Same scoping discipline as above — the coercion must live // Same scoping discipline as above — the coercion must live
// inside the pool factory's warm() body, not anywhere else. // inside the pool factory's warm() body, not anywhere else.
const poolBody = extractBlock(src, 'function pool(parent, mk)'); const poolBody = extractBlock(src, 'function pool(parent, mk)');
@@ -66,7 +61,7 @@ test('pool.warm coerces cap to a non-negative integer', () => {
}); });
test('warm() is called at boardInit with renderer-scoped cap constants', () => { test('warm() is called at boardInit with renderer-scoped cap constants', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// The note / chord / lane / beat cap constants live inside the // The note / chord / lane / beat cap constants live inside the
// boardInit/initScene path (renderer-instance scope, not module // boardInit/initScene path (renderer-instance scope, not module
// scope); each must exist as a const and drive at least one .warm() // scope); each must exist as a const and drive at least one .warm()
+12 -35
View File
@@ -41,41 +41,21 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// Since h3d-carve-1b, RENDER_ORDER_* constants and renderOrderForLayerAtZ
// live in geometry.js; screen.js imports them.
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
// h3d-carve-14: V-section moved to note-renderer.js; renderOrder tests must
// search both files.
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
let _src; let _src;
/** Returns screen.js + note-renderer.js concatenated for pattern matching. */ /** Returns the cached 3D highway screen source under test. */
function src() { function src() {
if (!_src) { if (!_src) _src = fs.readFileSync(SCREEN_JS, 'utf8');
_src = fs.readFileSync(SCREEN_JS, 'utf8')
+ '\n' + fs.readFileSync(NOTE_RENDERER_JS, 'utf8')
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8')
+ '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); // h3d-carve-16
}
return _src; return _src;
} }
let _geo; /** Parses the declared render-order layer stack from screen.js. */
/** Returns the cached geometry source (render-order constants + renderOrderForLayerAtZ). */
function geo() {
if (!_geo) _geo = fs.readFileSync(GEOMETRY_JS, 'utf8');
return _geo;
}
/** Parses the declared render-order layer stack from geometry.js. */
function layers() { function layers() {
const match = geo().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/); const match = src().match(/const\s+RENDER_ORDER_LAYER_STACK\s*=\s*Object\.freeze\(\s*\[([\s\S]*?)\]\s*\)/);
assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared'); assert.ok(match, 'RENDER_ORDER_LAYER_STACK must be declared');
return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]); return Array.from(match[1].matchAll(/'([^']+)'/g), m => m[1]);
} }
@@ -90,7 +70,7 @@ function layerIndex(name) {
/** Reads the render-order base used for objects at z = 0. */ /** Reads the render-order base used for objects at z = 0. */
function zZeroRenderOrder() { function zZeroRenderOrder() {
const match = geo().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/); const match = src().match(/const\s+RENDER_ORDER_AT_Z_ZERO\s*=\s*(-?\d+(?:\.\d+)?)\s*;/);
assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared'); assert.ok(match, 'RENDER_ORDER_AT_Z_ZERO must be declared');
return Number(match[1]); return Number(match[1]);
} }
@@ -142,8 +122,7 @@ test('board-projection frame mesh uses renderOrder 14', () => {
// Anchor to the board-projection pool (projMeshArr = activePalette.map(...)) // Anchor to the board-projection pool (projMeshArr = activePalette.map(...))
// so the assertion only passes when THAT block seeds renderOrder = 14 — // so the assertion only passes when THAT block seeds renderOrder = 14 —
// not any unrelated renderOrder = 14 elsewhere in the source. // not any unrelated renderOrder = 14 elsewhere in the source.
// h3d-carve-16: DI form uses setProjMeshArr(getActivePalette().map(...)) const boardProjRO = /projMeshArr\s*=\s*activePalette\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
const boardProjRO = /(?:projMeshArr\s*=\s*activePalette|setProjMeshArr\s*\(\s*getActivePalette\s*\(\s*\)\s*)\.map\b[\s\S]{0,1200}?m\.renderOrder\s*=\s*14\s*;/;
assert.match( assert.match(
src(), src(),
boardProjRO, boardProjRO,
@@ -192,10 +171,9 @@ test('static fret wires use bowed TubeGeometry + MeshStandardMaterial, named boa
/FRET_BOW_DZ\s*\*\s*zm/, /FRET_BOW_DZ\s*\*\s*zm/,
'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved', 'fret tube path must bow in Z by FRET_BOW_DZ so the neck reads as curved',
); );
// h3d-carve-16: DI form uses getFretTubeGeo() getter
assert.match( assert.match(
s, s,
/new\s+T\.Mesh\(\s*(?:fretTubeGeo|getFretTubeGeo\(\))\s*,\s*mat\s*\)/, /new\s+T\.Mesh\(\s*fretTubeGeo\s*,\s*mat\s*\)/,
'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)', 'buildBoard fret wires must reuse the shared fretTubeGeo (not T.Line)',
); );
assert.match( assert.match(
@@ -323,15 +301,14 @@ test('chordFrameRenderOrder uses renderOrderForLayerAtZ(z, CHORD_FRAME)', () =>
/const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/, /const\s+chordFrameRenderOrder\s*=\s*renderOrderForLayerAtZ\(\s*z\s*,\s*'CHORD_FRAME'\s*\)\s*;/,
'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)', 'chordFrameRenderOrder must use renderOrderForLayerAtZ(z, CHORD_FRAME)',
); );
// renderOrderForLayerAtZ implementation lives in geometry.js since h3d-carve-1b. assert.match(src(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/);
assert.match(geo(), /const\s+RENDER_ORDER_LAYER_INDEX\s*=\s*Object\.freeze\(\s*RENDER_ORDER_LAYER_STACK\.reduce\(/); assert.match(src(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/);
assert.match(geo(), /const\s+layerIndex\s*=\s*RENDER_ORDER_LAYER_INDEX\[layerName\]\s*;/); assert.match(src(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/);
assert.match(geo(), /if\s*\(\s*layerIndex\s*===\s*undefined\s*\)\s*throw\s+new\s+Error\(`Unknown 3D highway depth layer: \$\{layerName\}`\)\s*;/); assert.match(src(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
assert.match(geo(), /const\s+depthRenderOrder\s*=\s*Math\.max\(\s*RENDER_ORDER_FAR_CLAMP\s*,\s*Math\.round\(\s*RENDER_ORDER_AT_Z_ZERO\s*\+\s*worldZ\s*\/\s*K\s*\)\s*\)\s*;/);
// Layer is a sub-unit fraction so the integer depth bucket strictly // Layer is a sub-unit fraction so the integer depth bucket strictly
// dominates (a farther object can't outrank a nearer one via a higher // dominates (a farther object can't outrank a nearer one via a higher
// layer); the layer only breaks ties within the same depth bucket. // layer); the layer only breaks ties within the same depth bucket.
assert.match(geo(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/); assert.match(src(), /return\s+depthRenderOrder\s*\+\s*layerIndex\s*\/\s*RENDER_ORDER_LAYER_STACK\.length\s*;/);
assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE')); assert.ok(layerIndex('CHORD_FRAME') < layerIndex('NOTE_OUTLINE'));
}); });
-701
View File
@@ -1,701 +0,0 @@
// h3d-carve-15: U-section (per-frame renderer) pin tests.
//
// Guards:
// 1. Wiring: createRenderer factory exists in renderer.js and screen.js
// imports + calls it with the expected DI param count (179).
// 2. Kill tests: extracted private helpers are live in renderer.js; gut and
// restore proves RED.
// 3. Export contract: { update } returned by createRenderer.
// 4. Caller-list corrections: _applyNoteCamTargets and lookaheadSmoothCamStep
// have exactly the audited caller counts.
// 5. screen.js tombstone: original U-section bodies are absent from screen.js.
// 6. Wiring scope check: every shorthand identifier in every factory wiring call
// in screen.js resolves to a declared name — no phantoms (RED at 7623ad8 on
// BEAT_HEAD_SEC).
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const RENDERER_JS = path.join(
__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js'
);
const SCREEN_JS = path.join(
__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'
);
const src = fs.readFileSync(RENDERER_JS, 'utf8');
const screenSrc = fs.readFileSync(SCREEN_JS, 'utf8');
// ── 1. Wiring guard ──────────────────────────────────────────────────────────
test('createRenderer is exported from renderer.js', () => {
assert.match(src, /export function createRenderer/,
'renderer.js must export createRenderer');
});
test('screen.js imports createRenderer from renderer.js', () => {
assert.match(screenSrc, /import.*createRenderer.*from.*renderer\.js/,
'screen.js must import createRenderer');
});
test('screen.js wiring block contains expected DI param count (321)', () => {
// 321 = 117 getters + 60 setters + 144 shorthands
// 315→321: Creed re-check — 6 plain-value shorthands converted to getter+setter pairs:
// _drawAnchors, _drawChordTemplates, _drawNextByString, _drawRecentByString,
// _drawTeachingMarks, _showFingerHints. -6 shorthands, +6 getters, +6 setters = net +6.
// 313→315: +2 Toby r3 F1 fix: _CV_KEY_TIME_MUL, _CV_KEY_TIME_SLOT restored to
// screen.js scope and added as shorthands (were wrongly moved to renderer closure).
// 184→313: +129 carve-15 full completion:
// +43 Category B consts, +37 Category C fn-refs, +3 Category D getters,
// +27 Category E getter/setter pairs + 1 stable ref,
// +11 Category F (5 stable + 6 getter/setter), +5 Category G getters,
// +2 extra (chordFrameGradTex/Arp getters).
// 177→184: +7 Creed r1 F3 fixes: TS, S_BASE, FRET_LABEL_GOLD_HEX, FRET_LABEL_IDLE_HEX,
// lookaheadBootstrapTime, lookaheadComputeFretBounds, lookaheadTargetWorldX.
// 241→177: removed 44 phantom consts, 2 undefined fn-refs, 16 dead params,
// 2 shadowed locals camAhead/camTau.
const wiringMatch = screenSrc.match(/createRenderer\(\{([\s\S]*?)\}\)/);
assert.ok(wiringMatch, 'screen.js must contain createRenderer({...}) call');
const body = wiringMatch[1];
const getterCount = (body.match(/\bget[A-Z]\w+\s*:/g) || []).length;
const setterCount = (body.match(/\bset[A-Z]\w+\s*:/g) || []).length;
const shorthandCount = body.split('\n').reduce((acc, line) => {
const t = line.trim();
if (!t || t.startsWith('//') || t.includes('=>')) return acc;
return acc + (t.match(/\b[A-Za-z_][A-Za-z0-9_]*\b(?=\s*,)/g) || []).length;
}, 0);
const total = getterCount + setterCount + shorthandCount;
assert.strictEqual(total, 321,
`DI param count mismatch: got ${total} (getters=${getterCount}, setters=${setterCount}, shorthands=${shorthandCount})`);
});
// ── 2. Tombstone — original bodies must be absent from screen.js ─────────────
test('screen.js does not contain function lookaheadSmoothCamStep body', () => {
// Body was: Math.min(0.2, Math.max(1e-4, dtSec))
assert.doesNotMatch(screenSrc, /function lookaheadSmoothCamStep/,
'lookaheadSmoothCamStep body must be in renderer.js, not screen.js');
});
test('screen.js does not contain function _applyNoteCamTargets body', () => {
assert.doesNotMatch(screenSrc, /function _applyNoteCamTargets/,
'_applyNoteCamTargets body must be in renderer.js, not screen.js');
});
test('screen.js does not contain function _buildFretLabelSet body', () => {
assert.doesNotMatch(screenSrc, /function _buildFretLabelSet/,
'_buildFretLabelSet body must be in renderer.js, not screen.js');
});
test('screen.js does not contain function smoothNow body', () => {
// The name smoothNow also appears in camera.js; key is it should not
// appear in screen.js after the carve.
assert.doesNotMatch(screenSrc, /function smoothNow\b/,
'smoothNow body must be in renderer.js, not screen.js');
});
test('screen.js does not contain function update body (per-frame draw loop)', () => {
// The IIFE-level update() is gone. Key distinctive pattern: the region C
// song-change detection block (const newSongKey) only appears inside update().
// The wiring call has `const { update } = createRenderer(...)` not `function update(`.
assert.doesNotMatch(screenSrc, /function update\s*\(bundle\)/,
'function update(bundle) body must not appear in screen.js');
});
// ── 3. Renderer exports update ───────────────────────────────────────────────
test('renderer.js return value exports update, _prewarmStatic, _prewarmChart', () => {
// F1 fix: callers need _prewarmStatic/_prewarmChart from the factory return.
assert.match(src, /return\s*\{\s*update\s*,\s*_prewarmStatic\s*,\s*_prewarmChart\s*\}/,
'createRenderer must return { update, _prewarmStatic, _prewarmChart }');
});
// ── 4. Caller-list corrections (contract §6) ─────────────────────────────────
test('_applyNoteCamTargets has exactly 2 call sites in renderer.js', () => {
const calls = src.match(/_applyNoteCamTargets\s*\(/g) || [];
// Subtract 1 for the function declaration itself
const callSites = calls.length - 1;
assert.strictEqual(callSites, 2,
`_applyNoteCamTargets must have exactly 2 caller sites; found ${callSites}`);
});
test('lookaheadSmoothCamStep has exactly 3 call sites in renderer.js', () => {
// Strip comment lines before counting to avoid matching the comment mention.
const noComments = src.split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
const calls = noComments.match(/lookaheadSmoothCamStep\s*\(/g) || [];
const callSites = calls.length - 1; // subtract function declaration
assert.strictEqual(callSites, 3,
`lookaheadSmoothCamStep must have exactly 3 caller sites (9963/9974/9978); found ${callSites}`);
});
// ── 5. smoothNow return-value semantics (correction 3) ───────────────────────
test('smoothNow setter-return pattern: no bare return (_frameNow = ...) in renderer.js', () => {
// Must not use compound-assignment return; must use const v / setFrameNow / return v
assert.doesNotMatch(src, /return\s*\(\s*_frameNow\s*=/,
'smoothNow must not use return (_frameNow = raw); use setFrameNow + return v');
});
test('smoothNow uses setFrameNow before return in renderer.js', () => {
assert.match(src, /setFrameNow\(/,
'smoothNow must call setFrameNow() to persist frameNow');
});
// ── 6. Structural guard: createRenderer is after sub-factories in screen.js ──
test('createRenderer wiring is after createNoteRenderer in screen.js', () => {
const nrPos = screenSrc.indexOf('createNoteRenderer({');
const renPos = screenSrc.indexOf('createRenderer({');
assert.ok(renPos > nrPos,
'createRenderer({}) wiring must appear after createNoteRenderer({}) in screen.js');
});
test('createRenderer wiring is after createCamera in screen.js', () => {
const camPos = screenSrc.indexOf('createCamera({');
const renPos = screenSrc.indexOf('createRenderer({');
assert.ok(renPos > camPos,
'createRenderer({}) wiring must appear after createCamera({}) in screen.js');
});
// ── 7. Wiring scope check — every shorthand in all factory wirings is declared ──
// This test is the source-scan mitigation for wiring specifically:
// it was RED at 7623ad8 (BEAT_HEAD_SEC phantom failed; 44 phantoms total) and
// GREEN at the r2 fix tip.
//
// Strategy: for each wiring call block, extract shorthand identifier lines
// (no => arrow, no key: pattern), strip comment lines, collect identifier tokens.
// Then verify each appears in screen.js OUTSIDE the wiring block itself.
// A phantom never appears outside — so it fails here with a clear name.
test('all shorthand identifiers in factory wiring calls are declared in screen.js scope', () => {
// Extract shorthand tokens from the wiring body of a factory call.
// Lines containing '=>' are getter/setter arrow functions (skip).
// Lines whose only non-whitespace content is identifiers + commas are shorthand lines.
function extractShorthands(wiringBody) {
const names = new Set();
for (const line of wiringBody.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('//')) continue;
if (t.includes('=>')) continue;
// If line contains 'word:' pattern it's a key:value line — skip key (param name, not scope ref)
if (/\b\w+\s*:/.test(t)) continue;
const toks = t.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) || [];
for (const tok of toks) names.add(tok);
}
return names;
}
// Build corpus = screen.js with each wiring block blanked out.
// Names that only exist inside the wiring block → not in corpus → fail.
// Each wiring block is identified by its factory call signature.
const factoryPatterns = [
/createArp\(\{([\s\S]*?)\}\)/,
/createNoteRenderer\(\{([\s\S]*?)\}\)/,
/createCamera\(\{([\s\S]*?)\}\)/,
// After F1-prewarm fix the destructure has multiple names; match any {…update…} form.
/const \{[^}]*update[^}]*\} = createRenderer\(\{([\s\S]*?)\}\)/,
];
// Build scope corpus: screenSrc with all wiring blocks blanked
let corpus = screenSrc;
for (const pat of factoryPatterns) {
corpus = corpus.replace(pat, (m) => ' '.repeat(m.length));
}
const allMissing = [];
for (const pat of factoryPatterns) {
const m = screenSrc.match(pat);
if (!m) continue;
const shorthands = extractShorthands(m[m.length - 1]); // last capture group = body
for (const name of shorthands) {
// Check the name appears in the corpus (outside all wiring blocks)
if (!new RegExp(`\\b${name}\\b`).test(corpus)) {
allMissing.push(name);
}
}
}
// Anti-vacuity: if the createRenderer regex fails to match the wiring block
// (e.g. the destructure pattern changed), shorthands would be 0 and the loop
// silently passes with no actual checks. Assert a realistic floor.
{
const renPat = /const \{[^}]*update[^}]*\} = createRenderer\(\{([\s\S]*?)\}\)/;
const renM = screenSrc.match(renPat);
assert.ok(renM, 'createRenderer wiring regex must match screen.js — regex vacuity guard');
const renShorthands = extractShorthands(renM[renM.length - 1]);
assert.ok(renShorthands.size >= 150,
`createRenderer wiring must have >=150 shorthand params (got ${renShorthands.size}) — ` +
`regex matched too little or wiring block shrank unexpectedly`);
}
assert.deepEqual(allMissing.sort(), [],
`Shorthand identifiers not declared in screen.js scope (phantoms): ${allMissing.sort().join(', ')}\n` +
`This test was RED at 7623ad8 on BEAT_HEAD_SEC (44 phantoms). ` +
`Fix: delete undefined names from both the DI signature and wiring call.`);
});
// ── 8. Creed r1 execution-readiness guards (RED at 7180eff, GREEN at fix tip) ─
//
// Creed r1 review at 7180eff raised THREE HIGH findings — all runtime failures:
// F1: _prewarmStatic/_prewarmChart not returned → callers get undefined at
// screen.js:7377 and :7461
// F2: cameraLockLow/cameraLockZoom free vars in _applyNoteCamTargets → any
// fretted note in view triggers ReferenceError (cameraLockLow)
// F3: dZ/renderOrderForLayerAtZ not imported from geometry.js; TS/S_BASE/
// FRET_LABEL_* not DI'd → chord/beat/lane render paths crash (dZ)
// Plus: broken camera.js import (3 names not exported from camera.js) →
// module-load SyntaxError prevents renderer.js from loading at all.
//
// These source-scan guards are RED at 7180eff and GREEN at the fix commit.
test('F1: renderer.js returns _prewarmStatic and _prewarmChart', () => {
// RED at 7180eff: return { update } only — prewarm callers crash with TypeError
// GREEN at fix tip: return { update, _prewarmStatic, _prewarmChart }
assert.match(src, /_prewarmStatic\s*,\s*_prewarmChart/,
'return must include _prewarmStatic and _prewarmChart (F1 fix)');
assert.match(src, /return\s*\{[^}]*_prewarmStatic/,
'_prewarmStatic must be in return statement');
});
test('F2: _applyNoteCamTargets uses getCameraLockLow() not bare cameraLockLow', () => {
// Extract _applyNoteCamTargets body (from function decl to next top-level fn)
const fnStart = src.indexOf('function _applyNoteCamTargets(');
const fnEnd = src.indexOf('\nfunction ', fnStart + 1);
// Strip line comments so identifiers in comments don't trip the checks
const fnBody = src.slice(fnStart, fnEnd).replace(/\/\/[^\n]*/g, '');
// RED at 7180eff: cameraLockLow (free var, line 131); getCameraLockLow() absent
assert.doesNotMatch(fnBody, /\bcameraLockLow\b(?!\s*\()/,
'_applyNoteCamTargets must not read bare cameraLockLow (F2: use getCameraLockLow())');
assert.match(fnBody, /getCameraLockLow\(\)/,
'_applyNoteCamTargets must call getCameraLockLow() (F2 fix)');
assert.doesNotMatch(fnBody, /\bcameraLockZoom\b(?!\s*\()/,
'_applyNoteCamTargets must not read bare cameraLockZoom (F2: use getCameraLockZoom())');
assert.match(fnBody, /getCameraLockZoom\(\)/,
'_applyNoteCamTargets must call getCameraLockZoom() (F2 fix)');
});
test('F3: renderer.js imports dZ and renderOrderForLayerAtZ from geometry.js', () => {
// RED at 7180eff: neither name in geometry.js import — dZ calls crash
const importLine = src.match(/import\s*\{[^}]+\}\s*from\s*['"]\.\/geometry\.js['"]/);
assert.ok(importLine, 'renderer.js must have a geometry.js import');
assert.match(importLine[0], /\bdZ\b/,
'geometry.js import must include dZ (F3 fix)');
assert.match(importLine[0], /\brenderOrderForLayerAtZ\b/,
'geometry.js import must include renderOrderForLayerAtZ (F3 fix)');
});
test('F3: createRenderer DI includes TS, S_BASE, FRET_LABEL_GOLD_HEX, FRET_LABEL_IDLE_HEX', () => {
// RED at 7180eff: none of these in DI signature → undefined in hot render paths
const diMatch = src.match(/export function createRenderer\(\{([\s\S]*?)\}\s*\)/);
assert.ok(diMatch, 'createRenderer DI signature not found');
const di = diMatch[1];
for (const name of ['TS', 'S_BASE', 'FRET_LABEL_GOLD_HEX', 'FRET_LABEL_IDLE_HEX']) {
assert.match(di, new RegExp(`\\b${name}\\b`),
`createRenderer DI must include ${name} (F3 fix)`);
}
});
test('camera import fix: renderer.js does not import lookahead fns from camera.js', () => {
// At 7180eff camera.js only exports createCamera; importing the 3 lookahead names
// caused: SyntaxError: does not provide an export named 'lookaheadBootstrapTime'
// RED at 7180eff: those names in camera.js import → module-load failure
// GREEN at fix tip: removed from camera import, added to DI from screen.js
const cameraImport = src.match(/import\s*\{[^}]+\}\s*from\s*['"]\.\/camera\.js['"]/);
if (cameraImport) {
for (const name of ['lookaheadBootstrapTime', 'lookaheadComputeFretBounds', 'lookaheadTargetWorldX']) {
assert.doesNotMatch(cameraImport[0], new RegExp(`\\b${name}\\b`),
`renderer.js must not import ${name} from camera.js (not exported — causes module-load SyntaxError)`);
}
}
// Verify the 3 names appear in the DI signature instead
const diMatch = src.match(/export function createRenderer\(\{([\s\S]*?)\}\s*\)/);
assert.ok(diMatch, 'createRenderer DI not found');
const di = diMatch[1];
for (const name of ['lookaheadBootstrapTime', 'lookaheadComputeFretBounds', 'lookaheadTargetWorldX']) {
assert.match(di, new RegExp(`\\b${name}\\b`),
`createRenderer DI must include ${name} (camera import fix — DI'd from screen.js instead)`);
}
});
test('F1: screen.js destructures _prewarmStatic and _prewarmChart from createRenderer', () => {
// RED at 7180eff: const { update } = createRenderer({...}) — prewarm fns undefined
assert.match(screenSrc, /const\s*\{\s*update\s*,\s*_prewarmStatic\s*,\s*_prewarmChart\s*\}/,
'screen.js must destructure _prewarmStatic and _prewarmChart from createRenderer return');
});
// ── 26. Toby r3 F1 kill test — _CV_KEY_TIME consts in screen.js scope ────────
// _encodeChordVerdictKey is defined in screen.js IIFE scope and reads
// _CV_KEY_TIME_MUL / _CV_KEY_TIME_SLOT from that same scope. These were
// incorrectly moved to renderer.js closure in 06e4fe3, making them invisible to
// _encodeChordVerdictKey → ReferenceError on any chord-template chart frame.
// RED at 06e4fe3: consts absent from screen.js. GREEN at fix tip: restored.
test('_CV_KEY_TIME_MUL and _CV_KEY_TIME_SLOT are declared in screen.js before _encodeChordVerdictKey', () => {
const mulIdx = screenSrc.indexOf('const _CV_KEY_TIME_MUL');
const slotIdx = screenSrc.indexOf('const _CV_KEY_TIME_SLOT');
const fnIdx = screenSrc.indexOf('function _encodeChordVerdictKey');
assert.ok(mulIdx !== -1, '_CV_KEY_TIME_MUL must be declared in screen.js (not only in renderer.js closure)');
assert.ok(slotIdx !== -1, '_CV_KEY_TIME_SLOT must be declared in screen.js');
assert.ok(fnIdx !== -1, '_encodeChordVerdictKey must still exist in screen.js');
assert.ok(mulIdx < fnIdx, '_CV_KEY_TIME_MUL must be declared before _encodeChordVerdictKey in screen.js');
assert.ok(slotIdx < fnIdx, '_CV_KEY_TIME_SLOT must be declared before _encodeChordVerdictKey in screen.js');
});
// ── 27. Class-killer guard — no DI param assigned inside renderer.js ─────────
// Any assignment to a DI param name inside renderer.js is a silent state fork:
// the write lands in the local copy; the shared screen.js store never updates.
// This was the Creed re-check HIGH finding at a55dca7 (6 names: _drawAnchors,
// _drawChordTemplates, _drawNextByString, _drawRecentByString, _drawTeachingMarks,
// _showFingerHints). RED at a55dca7, GREEN at fix tip.
test('renderer.js does not assign to any DI param name (no silent state forks)', () => {
// Extract DI param names from the createRenderer({...}) signature.
const diMatch = src.match(/export function createRenderer\(\{([\s\S]*?)\}\s*\)/);
assert.ok(diMatch, 'createRenderer DI signature not found');
const diBody = diMatch[1];
// Collect tokens from the DI body. Skip getter/setter keys (word:) and arrow bodies.
const diNames = new Set();
for (const line of diBody.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('//') || t.includes('=>') || /\b\w+\s*:/.test(t)) continue;
for (const tok of (t.match(/\b[A-Za-z_][A-Za-z0-9_]*\b/g) || [])) diNames.add(tok);
}
assert.ok(diNames.size >= 100, `DI name extraction found only ${diNames.size} names — regex may have failed`);
// Strip line and block comments from the module body.
const body = src
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '');
// Find any assignment to a DI param: `name =`, `name +=`, etc.
// Exclude the DI destructure line itself and get/set decl lines.
const forks = [];
for (const name of diNames) {
// Match `name =` or `name +=` etc. NOT preceded by `get/set/const/let/var `.
const assignPat = new RegExp(`(?<!\\bconst |\\blet |\\bvar |\\bfunction )\\b${name}\\b\\s*[+\\-*\\/&|^%]?=(?!=)`, 'g');
const matches = [...body.matchAll(assignPat)];
if (matches.length > 0) forks.push(`${name} (${matches.length} assignment${matches.length > 1 ? 's' : ''})`);
}
assert.deepEqual(forks, [],
`DI params assigned in renderer.js (silent state fork): ${forks.join(', ')}\n` +
`Fix: replace \`name = value\` with \`setName(value)\` and add the setter to DI.`);
});
// ── 2325. ACTUAL EXECUTION SMOKE TEST ──────────────────────────────────────
// Loads createRenderer via new Function (strips ESM import/export) so it runs
// in a CJS test context with fully-stub DI. Proves update() does not throw.
//
// ⚠ new Function sloppy-mode hole: the stripped module runs outside strict mode.
// Reading an undeclared variable throws ReferenceError in BOTH strict and sloppy
// mode — only WRITING to an undeclared variable differs (sloppy creates a global;
// strict throws). So a missing DI param whose value is read will still throw here.
// The hole is the opposite: an undeclared DI param name that is only ever written
// (assigned) would silently create a global instead of throwing, making the smoke
// pass when the ES-module would have thrown at the assignment site. The compensating
// layer is eslint no-undef on renderer.js (enforced at commit time), which catches
// every undeclared read AND write regardless of assignment-vs-read. These two gates
// together provide the full guarantee: eslint=0 proves no undeclared names; smoke
// proves update() executes end-to-end without ReferenceError on the read paths.
//
// RED at d475899: first execution would crash with
// ReferenceError: ACCENT_NOTE_FILL_BOOST is not defined
// because Category-B consts were read from renderer.js scope but were never
// declared inside it (they lived only in screen.js's IIFE and ES-module scope
// never chains into an IIFE). GREEN at this commit: all 313+ DI params wired.
{
// Stub window for Node (renderer.js reads window.feedBack, guarded by &&)
if (typeof global.window === 'undefined') global.window = {};
// ── Geometry stubs (replace the geometry.js import) ──────────────────────
const _geo = {
lowerBoundT(arr, t) {
let lo = 0, hi = arr.length;
while (lo < hi) { const m = (lo + hi) >> 1; if (arr[m].t < t) lo = m + 1; else hi = m; }
return lo;
},
camBaseDistU: () => 0,
camLowFretPullbackU: () => 0,
dZ: () => 0,
renderOrderForLayerAtZ: () => 0,
};
// Strip ESM: remove import lines, rename export function
const _stripped = src
.replace(/^import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"]\s*;?[^\n]*/mg, '')
.replace('export function createRenderer', 'function createRenderer');
// Wrap in a function that closes over geometry helpers and returns the factory
const _getFactory = new Function(
'lowerBoundT', 'camBaseDistU', 'camLowFretPullbackU', 'dZ', 'renderOrderForLayerAtZ',
_stripped + '\nreturn createRenderer;',
);
const _createRenderer = _getFactory(
_geo.lowerBoundT, _geo.camBaseDistU, _geo.camLowFretPullbackU,
_geo.dZ, _geo.renderOrderForLayerAtZ,
);
// ── Build a minimal-stub DI covering all 313 params ──────────────────────
const N = () => {};
const NAR = new Float32Array(0);
const NSTR = 6, NFRETS = 24;
function _makeDI() {
return {
// B — consts
K: 1, NFRETS, NW: 1, NH: 0.1, AHEAD: 1.5, BEHIND: 0.2, S_GAP: 1,
CAM_FOCUS_BLEND_RATE: 0.1, CAM_LOCK_ZOOM_MIN: 0.5, CAM_LOCK_ZOOM_MAX: 2,
CAM_LOCK_CENTER_FRET: 7, LOOKAHEAD_LOCK_ENGAGE_MAXF: 3, LOOKAHEAD_LOCK_RELEASE_MAXF: 5,
DEFAULT_LOOKAHEAD_FRET_SPAN: 8, FRET_WIDTH_MID: 0.05, CAM_TGT_BEHIND: 0.2,
CAM_DIST_BASE: 5, VENUE_GEM_EMISSIVE_MUL: 1.5, NOTEDETECT_GEM_VERDICT_WINDOW: 0.3,
INLAY_LABEL_FRETS: [3,5,7,9,12], GHOST_HOLD_AFTER_ONSET: 0.1,
CHORD_FRAME_RIM_MIN: 0.01, CHORD_FRAME_RIM_FRAC_H: 0.1,
TS: 1, S_BASE: 0.1, FRET_LABEL_GOLD_HEX: '#e8c040', FRET_LABEL_IDLE_HEX: '#9ab8cc',
ACCENT_NOTE_FILL_BOOST: 0.3, ACCENT_NOTE_LINGER_EPS: 0.05, ACCENT_NOTE_STR_GLOW: 0.5,
ARPEGGIO_RIM_BLUE_HEX: '#4080ff', ARP_FRAME_ONSET_CLUSTER_S: 0.1,
ARP_FRAME_ONSET_PAD_S: 0.05, ARP_INFER_MIN_HAND_SHAPE_SPAN_S: 0.2,
CAM_DIST_HYST_C: 0.1, CAM_DIST_HYST_T: 0.1, CAM_TGT_AHEAD_C: 0.1,
CAM_TGT_AHEAD_T: 0.1, CAM_TGT_HYST_C: 0.05, CAM_TGT_HYST_T: 0.05,
CAM_TGT_TAU_C: 0.2, CAM_TGT_TAU_T: 0.2, CHORD_BOX_EDGE_ALPHA: 0.7,
CHORD_BOX_HIT_BRIGHT_HEX: '#fff', CHORD_BOX_MISS_DARK_HEX: '#333',
CHORD_BOX_TEAL_HEX: '#00ac', CHORD_FRAME_RIM_Z_MIN: 0.1,
CHORD_FRAME_RIM_Z_SCAL: 1, CHORD_HWY_FADE_S: 0.3, CHORD_HWY_LINGER_S: 2,
DIAG_CROSSFADE_S: 0.15, DIAG_ENTRANCE_S: 0.2, DIAG_LINGER_S: 1.5,
DOTS: [3,5,7,9,12,15,17,19,21], FRET_COOLDOWN: 0.15, FRET_EMISSIVE: 2,
FRET_WIRE_ACTIVE_HEX: '#80c0ff', FRET_WIRE_ACTIVE_OP: 0.9,
FRET_WIRE_HIT_DECAY: 0.9, FRET_WIRE_HIT_INTENSITY: 3, FRET_WIRE_HIT_OP: 1,
FRET_WIRE_IDLE_HEX: '#aaa', FRET_WIRE_IDLE_OP: 0.3,
HWY_LANE_STRIPE_OP_BASE: 0.3, HWY_LANE_STRIPE_OP_INT: 0.15,
HWY_LANE_TIME_SLICES: 8, NEXT_ON_STRING_T_EPS: 0.01,
_ND_UNMATCHED_LATCH_AFTER: 0.2, VENUE_LANE_OP_BOOST: 0.5,
_CV_KEY_TIME_MUL: 1e4, _CV_KEY_TIME_SLOT: 1e6,
MAX_RENDER_STRINGS: 8,
// C — fn-refs
sY: (s) => s * 0.1, xFret: (f) => f * 0.05, xFretMid: (f) => f * 0.05,
fretLabelScaleForFret: () => 1, pbBeg: N, pbEnd: N, pbReportTick: N,
hwyFirstRelevantFrettedTime: () => Infinity, _syncOpenStringPitchLabels: N,
txtMat: () => ({ opacity: 1, map: null, color: { lerp: N }, emissive: { lerp: N }, emissiveIntensity: 1 }),
_setLabelMap: N, drawNote: N, drawArpBrackets: N, chordHarmonyLabels: N,
camUpdate: N, lookaheadBootstrapTime: N,
lookaheadComputeFretBounds: () => ({ lo: 0, hi: 12 }),
lookaheadTargetWorldX: () => 0, chordWireHighDensity: () => false,
chordTemplateLabel: () => null, chordTemplateMarkedArpeggio: () => false,
chordHandShapeArpeggioHint: () => false,
mergeHandShapeSynthChords: () => [], mergeChordShape: () => null,
inferArpeggioFromNotePattern: N, chordShapeCoveredByStandaloneNotes: () => false,
hsStart: () => 0, hsEnd: () => 0, handShapeChartSpanSec: () => 0.5,
fillArpeggioGhostInferFlags: N, arpeggioChordIdForNoteWithInferCache: () => -1,
arpHsBoundsForNote: () => null, fillLaneRailHandShapeFlags: N,
fillArpeggioRailShapeBoundsCaches: N,
arpeggioLaneOuterRailLaneSlice: () => null,
arpeggioLaneOuterRailAtChartTime: () => null,
arpeggioLaneDividerFrameAccentMul: () => 1,
arpeggioLaneDividerXYScaleMatchFrameRim: () => 1,
validString: (s) => s >= 0 && s < NSTR, filterValidNotes: (n) => n,
activePalette: new Array(NSTR).fill(0xffffff),
anchorLaneBoundsAt: () => null, anchorPlayedFretSpanAt: () => null,
boardSpanX: 1, chordShapeSignature: () => '',
// Shared-mutable-state pairs (Creed re-check fix: was plain-value shorthands)
getDrawAnchors: () => [], setDrawAnchors: N,
getDrawChordTemplates: () => [], setDrawChordTemplates: N,
getDrawNextByString: () => new Array(NSTR).fill(null), setDrawNextByString: N,
getDrawRecentByString: () => new Array(NSTR).fill(null), setDrawRecentByString: N,
getDrawTeachingMarks: () => false, setDrawTeachingMarks: N,
getShowFingerHints: () => false, setShowFingerHints: N,
_encodeChordVerdictKey: (t, s, f) => `${t}_${s}_${f}`,
_firstEventTimeGreaterThan: () => Infinity,
fretColumnMarkerCadence: 0, fretColumnMarkersForAnchor: () => [],
fretDividersVisible: true, fretLastActiveTime: new Float32Array(NFRETS + 1),
_fretMarkerWaveCache: {}, fretWireMats: [],
fretX: (f) => f * 0.05,
getChartAnchorAt: () => ({ fret: 0, width: 12 }), hwyPostHitTailFadeMul: () => 1,
imFHTech: null, imFHXFill: null, imFHXLines: null,
imPMTech: null, imPMXFill: null, imPMXLines: null,
laneBoundsFromAnchor: () => ({ lo: 0, hi: 12 }), sectionLabelsOnHighway: false,
updateStringHighlights: N,
_noteKey: (t, s) => `${t}_${s}`,
bendChevronMat: () => null, darkenHex: (h) => h, slideArrowMat: () => null,
triMat: () => null, palmMuteXSpriteMat: () => null,
fretHandMuteXSpriteMat: () => null, fxClearSeen: N,
// D — ren/scene/cam getters
getRen: () => null, getScene: () => null, getCam: () => null,
// E — shared mutable (getter/setter)
getDiagChord: () => null, setDiagChord: N,
getDiagEntranceT: () => 1, setDiagEntranceT: N,
getDiagLastKey: () => null, setDiagLastKey: N,
getDiagPrev: () => null, setDiagPrev: N,
getDiagPrevOpacity: () => 0, setDiagPrevOpacity: N,
getDiagPrevStartOpacity: () => 0, setDiagPrevStartOpacity: N,
getDiagPrevStartT: () => null, setDiagPrevStartT: N,
getMergeCacheResult: () => null, setMergeCacheResult: N,
_scrEventTimes: new Float64Array(256),
getScrEventTimesLen: () => 0, setScrEventTimesLen: N,
getSlideTargetChordsRef: () => null, setSlideTargetChordsRef: N,
getSlideTargetNotesRef: () => null, setSlideTargetNotesRef: N,
getSlideTargetSet: () => null, setSlideTargetSet: N,
// F — stable refs + getter/setter
_fwChordAcc: new Map(), _fwHitGlow: new Float32Array(NFRETS + 1),
_fwHitIn: new Float32Array(NFRETS + 1), _rimFlashIn: new Float32Array(NSTR),
_susVerdictLatch: new Map(),
getFwHitColor: () => null, getFwHitEmissive: () => null,
getFwHitPrevTime: () => -Infinity, setFwHitPrevTime: N,
getMBeatM: () => null, getMBeatQ: () => null, getMRimFlash: () => [],
// G — lane materials
getMLaneDivider: () => ({ opacity: 1, color: { lerp: N }, emissive: { lerp: N } }),
getMLaneDividerArp: () => ({ opacity: 1 }),
getMLaneDividerExt: () => ({ opacity: 1 }),
getMLaneEven: () => ({ opacity: 1 }),
getMLaneOdd: () => ({ opacity: 1 }),
// Extra
getChordFrameGradTex: () => null, getChordFrameGradTexArp: () => null,
// Pool getters (33)
getPNote: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPNoteEdge: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSus: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSusOutline: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSusRibbon: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSusRibbonOl: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPTapChevron: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPAccentHalo: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPLbl: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPArpBracket: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPBeat: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSec: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPFretLbl: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPLane: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPLaneDivider: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPGhostFretLbl: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPChordBox: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPChordFrameFill: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPChordLbl: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPBarreLine: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPHaloBar: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPPMXFill: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPFHXFill: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPMuteXLines: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPFHXLines: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPNoteFretLabel: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPConnectorLine: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPDropLine: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPTeachMarkLbl: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPFretColMarker: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSusRail: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPSusRailBloom: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
getPTechPlane: () => ({ get: () => ({}), release: N, resetCount: N, reset: N }),
// Material/scene getters
getMHitBright: () => [], getMHitSusOutline: () => null,
getGlowMul: () => 1, getVenueSceneOverride: () => false, getProjMeshArr: () => [],
// Render settings
getTextSize: () => 12, getCameraMode: () => 'smooth',
getCameraSmoothing: () => 0.5, getZoomSmoothing: () => 0.5,
getCameraLockLow: () => false, getCameraLockZoom: () => 0,
getNStr: () => NSTR, getLeftyCached: () => false, getInverted: () => false,
// Camera state getters
getTgtX: () => 0.3, getTgtDist: () => 5, getCurX: () => 0.3,
getPrevLowFretBonus: () => 0, getPrevLockActive: () => false,
getLookaheadCamX: () => 0.3, getLookaheadFretSpan: () => 8,
getLookaheadLowBonusU: () => 0, getLookaheadHiNeckLatch: () => false,
getLookaheadCamPrevNow: () => 0,
getFrameNow: () => 0, getClkAudioT: () => 0, getClkPerf: () => 0,
getClkRate: () => 1, getCamSnapped: () => true, getCamPreScanned: () => true,
getCamBootstrapHolding: () => false, getCamBootstrapMode: () => 'snap',
getSongKey: () => 'smoke-test', getNdVerdictSawAlpha: () => false,
getNdVerdictMaxAlpha: () => 0, getNdFrameNowMs: () => 0,
getInlayLabels: () => [], getLeanSusPollCounter: () => 0, getLeanSus: () => true,
getTextSizeMul: () => 1, getTextSizeMulApplied: () => 1,
getImPMTechCount: () => 0, getImFHTechCount: () => 0,
getMeasureStartsRef: () => [],
// Stable object refs
_frameLabeledKeys: new Set(), _ndLabels: [],
_scrGhostUpcomingCount: new Int32Array(NSTR),
_ndHitMarks: [], _ndMissMarks: [],
// Setters
setNdVerdictSawAlpha: N, setNdVerdictMaxAlpha: N, setNdFrameNowMs: N,
setLeanSus: N, setLeanSusPollCounter: N,
setTextSizeMul: N, setTextSizeMulApplied: N,
setImPMTechCount: N, setImFHTechCount: N,
setImPMXFillCount: N, setImPMXLinesCount: N,
setImFHXFillCount: N, setImFHXLinesCount: N,
setLookaheadCamX: N, setLookaheadFretSpan: N,
setLookaheadCamPrevNow: N, setLookaheadHiNeckLatch: N,
setLookaheadLowBonusU: N, setTgtX: N, setTgtDist: N,
setPrevLowFretBonus: N, setPrevLockActive: N,
setCurX: N, setCurDist: N, setSongKey: N, setCamSnapped: N,
setCamPreScanned: N, setCamBootstrapHolding: N, setCamBootstrapMode: N,
setMeasureStarts: N, setMeasureStartsRef: N,
setClkAudioT: N, setClkPerf: N, setClkRate: N, setFrameNow: N,
};
}
function _makeBundle(o) {
return Object.assign({
currentTime: 1.0, notes: [], chords: [], beats: [], sections: [],
anchors: [{ time: 0, fret: 0, width: 12 }], chordTemplates: [],
stringCount: NSTR, lyricsVisible: false, toneChanges: [], phrases: null,
isReady: true, mastery: 1, hasPhraseData: false,
songInfo: { arrangement: 'lead', tuning: [0,0,0,0,0,0], capo: 0, centOffset: 0 },
lowerBoundT: _geo.lowerBoundT,
lowerBoundTime: (arr, t) => _geo.lowerBoundT(arr, t),
project: () => ({ x: 0, y: 0 }), fretX: (f) => f * 0.05,
getNoteState: () => null,
}, o);
}
test('smoke: createRendererFn constructs without throw', () => {
assert.doesNotThrow(() => _createRenderer(_makeDI()),
'createRenderer(stub-DI) must not throw — all names must be provided');
});
test('smoke: update() with empty bundle throws no ReferenceError (proves no undeclared names)', () => {
// RED at d475899: ACCENT_NOTE_FILL_BOOST is not defined (Category B, not DI'd).
// GREEN at this commit: all 313 DI params wired in createRenderer signature.
// TypeErrors from stub DI (incomplete Three.js objects) are expected and accepted;
// what must NOT happen is a ReferenceError for an undeclared name.
const renderer = _createRenderer(_makeDI());
try {
renderer.update(_makeBundle({}));
} catch (e) {
assert.notStrictEqual(e.constructor, ReferenceError,
`update() must not throw ReferenceError — undeclared name: ${e.message}`);
}
});
test('smoke: update() backward seek (region C) throws no ReferenceError', () => {
// Backward seek triggers _susVerdictLatch.clear() and slide-target reset.
// TypeErrors from stub DI are expected; ReferenceError proves an undeclared name.
const di = _makeDI();
di.getFrameNow = () => 2.0;
const renderer = _createRenderer(di);
try { renderer.update(_makeBundle({ currentTime: 2.0 })); } catch (_) {}
try {
renderer.update(_makeBundle({ currentTime: 0.5 })); // backward seek
} catch (e) {
assert.notStrictEqual(e.constructor, ReferenceError,
`update() backward seek must not throw ReferenceError: ${e.message}`);
}
});
// Kill test — shared-state setter pairs actually mutate backing store (Creed re-check)
test('kill test: setDrawNextByString call mutates backing store (not a local fork)', () => {
// RED at a55dca7: `_drawNextByString = nextNoteByString` wrote the DI local only;
// backing store (screen.js closure) stayed at sentinel null — drawNote saw stale null.
// GREEN here: `setDrawNextByString(nextNoteByString)` calls the setter in the DI,
// which updates the backing let. Sentinel = null (same as initial screen.js value).
// After one update() with a future note, drawNextByString_store must be non-null.
const SENTINEL = null;
let drawNextByString_store = SENTINEL;
const di = _makeDI();
di.setDrawNextByString = (v) => { drawNextByString_store = v; };
di.getDrawNextByString = () => drawNextByString_store;
const renderer = _createRenderer(di);
const futureNote = { t: 10, s: 0, f: 5, sus: 0, ho: false, po: false };
try { renderer.update(_makeBundle({ notes: [futureNote] })); } catch (_) {}
assert.notStrictEqual(drawNextByString_store, SENTINEL,
`setDrawNextByString was never called — backing store stayed at sentinel null. ` +
`RED at a55dca7 (plain assignment forked the DI local). GREEN here: setter call.`);
});
}
-465
View File
@@ -1,465 +0,0 @@
// Source-level guards for src/scene-init.js (h3d-carve-16).
// Validates wiring, DI contract, class-killer, export surface, and kill tests
// for initScene / buildBoard / _bgUnmountStyle / _bcSyncMode.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const sceneInitJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js');
const screen3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const pluginJson = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'plugin.json');
// ── §1 Wiring guard ───────────────────────────────────────────────────────────
test('scene-init exports createSceneInit as a named ES export', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /^export\s+function\s+createSceneInit\s*\(/m,
'must have: export function createSceneInit(');
});
test('screen.js imports createSceneInit from ./src/scene-init.js', () => {
const src = fs.readFileSync(screen3dJs, 'utf8');
assert.match(src, /import\s*\{[^}]*createSceneInit[^}]*\}\s*from\s*['"]\.\/src\/scene-init\.js['"]/,
'screen.js must import createSceneInit from ./src/scene-init.js');
});
test('screen.js wires the four exports from createSceneInit', () => {
const src = fs.readFileSync(screen3dJs, 'utf8');
assert.match(src, /const\s*\{[^}]*initScene[^}]*\}\s*=\s*createSceneInit\s*\(/,
'screen.js must destructure initScene from createSceneInit(...)');
assert.match(src, /const\s*\{[^}]*buildBoard[^}]*\}\s*=\s*createSceneInit\s*\(/,
'screen.js must destructure buildBoard from createSceneInit(...)');
assert.match(src, /const\s*\{[^}]*_bgUnmountStyle[^}]*\}\s*=\s*createSceneInit\s*\(/,
'screen.js must destructure _bgUnmountStyle from createSceneInit(...)');
assert.match(src, /const\s*\{[^}]*_bcSyncMode[^}]*\}\s*=\s*createSceneInit\s*\(/,
'screen.js must destructure _bcSyncMode from createSceneInit(...)');
});
// ── §2 Export surface ─────────────────────────────────────────────────────────
test('createSceneInit returns exactly { initScene, buildBoard, _bgUnmountStyle, _bcSyncMode }', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
// The return statement at the bottom of createSceneInit must name exactly these four
assert.match(src,
/return\s*\{\s*initScene\s*,\s*buildBoard\s*,\s*_bgUnmountStyle\s*,\s*_bcSyncMode\s*\}/,
'return surface must be exactly { initScene, buildBoard, _bgUnmountStyle, _bcSyncMode }');
});
test('createSceneInit does NOT export _bgLoadSettings (internal function)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
// _bgLoadSettings is internal; must not appear in the return object
assert.doesNotMatch(src,
/return\s*\{[^}]*_bgLoadSettings[^}]*\}/,
'_bgLoadSettings must NOT be in the return surface');
});
// ── §3 Class-killer guard ─────────────────────────────────────────────────────
test('createSceneInit body never assigns to a DI parameter name (class-killer)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
// Extract the DI parameter block (between first { and the closing }) of the factory signature
const sigStart = src.indexOf('export function createSceneInit({');
assert.ok(sigStart !== -1, 'createSceneInit signature not found');
const bodyOpen = src.indexOf(') {', sigStart);
assert.ok(bodyOpen !== -1, 'factory body open not found');
const paramBlock = src.slice(sigStart, bodyOpen);
// Collect setter names (setX) from the DI block
const setterNames = [...paramBlock.matchAll(/\bset([A-Z][A-Za-z0-9]*)\b/g)].map(m => m[0]);
assert.ok(setterNames.length > 10, `expected many setter params, got ${setterNames.length}`);
const body = src.slice(bodyOpen);
for (const name of setterNames) {
// Assignment to the bare DI name (not a call) would be: `name = ` or `name=`
const assignPat = new RegExp(`\\b${name}\\s*=(?!=)`, 'g');
const hits = body.match(assignPat);
assert.ok(!hits, `class-killer: body assigns to DI param '${name}' (${hits && hits.length} hit(s))`);
}
});
// ── §4 DI anti-vacuity ────────────────────────────────────────────────────────
test('createSceneInit receives ≥150 DI parameters (anti-vacuity floor)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
const sigStart = src.indexOf('export function createSceneInit({');
const bodyOpen = src.indexOf('\n}) {', sigStart);
const paramBlock = src.slice(sigStart, bodyOpen);
const names = new Set();
for (const line of paramBlock.split('\n')) {
const t = line.trim();
if (!t || t.startsWith('//') || t.startsWith('/*') || t.startsWith('*')) continue;
const m = t.match(/^([A-Za-z_$][A-Za-z0-9_$]*)/);
if (m && m[1] !== 'export' && m[1] !== 'function' && m[1] !== 'createSceneInit') {
names.add(m[1]);
}
}
// Anti-vacuity floor — if regex changes and extracts 0, this fails loudly
assert.ok(names.size >= 150, `anti-vacuity: expected ≥150 DI params, got ${names.size}`);
// Exact pinned count — update this if DI surface intentionally changes
// Cut-16 tip: 183. After F2 (remove 5 dead-param lines): 178. Cut-17 alias removal: 179.
// (removing the alias moved setHighwayCanvas to line-start, adding it to the line-first count)
assert.strictEqual(names.size, 179,
`exact DI param count must be 179 (got ${names.size}) — update if DI surface changes`);
});
// ── §5 Import correctness ─────────────────────────────────────────────────────
test('scene-init imports geoFretX and geoFretMid from geometry.js (not bare fretX/fretMid)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /import\s*\{[^}]*geoFretX[^}]*\}\s*from\s*['"]\.\/geometry\.js['"]/,
'must import geoFretX from ./geometry.js');
assert.match(src, /import\s*\{[^}]*geoFretMid[^}]*\}\s*from\s*['"]\.\/geometry\.js['"]/,
'must import geoFretMid from ./geometry.js');
// Should NOT import the bare names that don't exist in geometry.js
assert.doesNotMatch(src,
/import\s*\{[^}]*(?<!\w)fretX(?!\w)[^}]*\}\s*from\s*['"]\.\/geometry\.js['"]/,
'must NOT import bare fretX from geometry.js (it exports geoFretX)');
});
test('scene-init rebuilds fretX/fretMid as closures over getH3dFretUniform()', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /const\s+fretX\s*=\s*f\s*=>\s*geoFretX\s*\(\s*f\s*,\s*getH3dFretUniform\s*\(\s*\)\s*\)/,
'fretX must be rebuilt as: f => geoFretX(f, getH3dFretUniform())');
assert.match(src, /const\s+fretMid\s*=\s*f\s*=>\s*geoFretMid\s*\(\s*f\s*,\s*getH3dFretUniform\s*\(\s*\)\s*\)/,
'fretMid must be rebuilt as: f => geoFretMid(f, getH3dFretUniform())');
});
// ── §6 Key functions present ───────────────────────────────────────────────────
test('initScene is defined as a function inside createSceneInit body', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /function\s+initScene\s*\(\s*\)/,
'initScene() must be defined inside scene-init.js');
});
test('buildBoard is defined as a function inside createSceneInit body', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /function\s+buildBoard\s*\(\s*\)/,
'buildBoard() must be defined inside scene-init.js');
});
test('_bgUnmountStyle is defined as a function inside createSceneInit body', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /function\s+_bgUnmountStyle\s*\(\s*\)/,
'_bgUnmountStyle() must be defined inside scene-init.js');
});
test('_bcSyncMode is defined as a function inside createSceneInit body', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /function\s+_bcSyncMode\s*\(\s*\)/,
'_bcSyncMode() must be defined inside scene-init.js');
});
// ── §7 Kill tests — functions that must NOT survive in screen.js ───────────────
test('initScene no longer defined in screen.js (moved to scene-init.js)', () => {
const src = fs.readFileSync(screen3dJs, 'utf8');
assert.doesNotMatch(src, /^\s*function\s+initScene\s*\(\s*\)/m,
'initScene() must not be defined in screen.js — it moved to scene-init.js');
});
test('buildBoard no longer defined in screen.js (moved to scene-init.js)', () => {
const src = fs.readFileSync(screen3dJs, 'utf8');
assert.doesNotMatch(src, /^\s*function\s+buildBoard\s*\(\s*\)/m,
'buildBoard() must not be defined in screen.js — it moved to scene-init.js');
});
test('_bgUnmountStyle no longer defined in screen.js (moved to scene-init.js)', () => {
const src = fs.readFileSync(screen3dJs, 'utf8');
assert.doesNotMatch(src, /^\s*function\s+_bgUnmountStyle\s*\(\s*\)/m,
'_bgUnmountStyle() must not be defined in screen.js — it moved to scene-init.js');
});
test('_bcSyncMode no longer defined in screen.js (moved to scene-init.js)', () => {
const src = fs.readFileSync(screen3dJs, 'utf8');
assert.doesNotMatch(src, /^\s*function\s+_bcSyncMode\s*\(\s*\)/m,
'_bcSyncMode() must not be defined in screen.js — it moved to scene-init.js');
});
// ── §8 plugin.json version bump ───────────────────────────────────────────────
test('plugin.json version is 3.53.0 (bumped for cut-17)', () => {
const pkg = JSON.parse(fs.readFileSync(pluginJson, 'utf8'));
assert.equal(pkg.version, '3.53.0',
'plugin.json must be bumped to 3.53.0 for cut-17');
});
// ── §9 Setter-call kill tests (§10 of contract) ───────────────────────────────
// Each asserts the setter is called inside the moved function body.
// Gut the call → test goes RED. Catches omission (function stops writing to
// factory scope silently), not caught by class-killer (which only catches
// assignment to DI param names, not missing setter calls).
test('kill: initScene body calls setRen(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetRen\s*\(/,
'initScene must call setRen() — gut it and the renderer ref is never stored');
});
test('kill: initScene body calls setScene(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetScene\s*\(/,
'initScene must call setScene() — gut it and the Three.js scene ref is never stored');
});
test('kill: initScene body calls setPNote(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetPNote\s*\(/,
'initScene must call setPNote() — gut it and note pool is never stored; draw() cannot recycle gems');
});
test('kill: initScene body calls setWrap(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetWrap\s*\(/,
'initScene must call setWrap() — gut it and the DOM overlay element is never stored');
});
test('kill: buildBoard body calls setBoardStringStartX(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetBoardStringStartX\s*\(/,
'buildBoard must call setBoardStringStartX() — gut it and renderer.js reads stale fretX(0) forever');
});
test('kill: buildBoard body calls setFretWireMats(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetFretWireMats\s*\(/,
'buildBoard must call setFretWireMats() — gut it and wire material array is never updated after rebuild');
});
test('kill: _bgLoadSettings body calls setActivePalette(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetActivePalette\s*\(/,
'_bgLoadSettings must call setActivePalette() — gut it and renderer.js reads stale palette (silent fork class)');
});
test('kill: _bgLoadSettings body calls setCameraMode(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetCameraMode\s*\(/,
'_bgLoadSettings must call setCameraMode() — gut it and camera mode never updates after settings change');
});
test('kill: _bgLoadSettings body calls setGlowMul(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetGlowMul\s*\(/,
'_bgLoadSettings must call setGlowMul() — gut it and emissive intensity never updates after vibrancy change');
});
test('kill: _bcSyncMode body calls setBcCtrl(', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /\bsetBcCtrl\s*\(/,
'_bcSyncMode must call setBcCtrl() — gut it and bcCtrl in screen.js scope is never updated; BC stays dead');
});
// F1 kill: ternary must be INSIDE the setter argument (not truncated to boolean).
// Mutation: add extra ) after _bgHasStored(...) closing paren → argument becomes a bare boolean
// → argument text has no '?' → RED.
// A helper to extract the full argument (handles nested parens).
function extractSetterArg(src, fnName) {
const idx = src.indexOf(fnName + '(');
if (idx === -1) return null;
let depth = 0, argStart = -1, i = idx + fnName.length;
while (i < src.length) {
if (src[i] === '(') { if (depth === 0) argStart = i + 1; depth++; }
else if (src[i] === ')') { depth--; if (depth === 0) return src.slice(argStart, i); }
i++;
}
return null;
}
test('kill: setZoomSmoothing argument contains ternary ? (not truncated to boolean)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
const arg = extractSetterArg(src, 'setZoomSmoothing');
assert.ok(arg !== null, 'setZoomSmoothing call must exist in scene-init.js');
assert.ok(arg.includes('?'),
'setZoomSmoothing argument must include ternary ? — ' +
'if missing, the boolean condition was stored instead of the camera-smoothing value (F1 regression)');
});
test('kill: setTiltSmoothing argument contains ternary ? (not truncated to boolean)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
const arg = extractSetterArg(src, 'setTiltSmoothing');
assert.ok(arg !== null, 'setTiltSmoothing call must exist in scene-init.js');
assert.ok(arg.includes('?'),
'setTiltSmoothing argument must include ternary ? — ' +
'if missing, the boolean condition was stored instead of the camera-smoothing value (F1 regression)');
});
// ── §9 Naming-correspondence guard ────────────────────────────────────────────
// For every getX in the DI signature, assert a matching setX exists — or the
// getter is in READ_ONLY (stable state never written by scene-init).
// Mutation: rename setWrap → setWrp in scene-init.js DI → getWrap has no pair → RED.
test('createSceneInit DI: every getX has a corresponding setX (naming correspondence)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
const sigStart = src.indexOf('export function createSceneInit({');
const bodyOpen = src.indexOf('\n}) {', sigStart);
const paramBlock = src.slice(sigStart, bodyOpen);
const setters = new Set(
[...paramBlock.matchAll(/\bset([A-Z][A-Za-z0-9]*)\b/g)].map(m => m[1])
);
const getters = [
...paramBlock.matchAll(/\bget([A-Z][A-Za-z0-9]*)\b/g)
].map(m => m[1]);
// Stable read-only refs: screen.js never writes these after initial capture.
// scene-init receives getX but has no setX because it never needs to update them.
// Pinned: update only when a new stable-ref getter is added to the DI.
const READ_ONLY = new Set([
'BgReactiveOptOut', 'H3dFretUniform', 'InstanceId',
'LeftyCached', 'NStr', 'VenueSceneOverride',
]);
for (const g of getters) {
if (READ_ONLY.has(g)) continue;
assert.ok(setters.has(g),
`DI naming gap: get${g} has no matching set${g}` +
`add setter to DI or add to READ_ONLY list in this test`);
}
});
// ── §10 Execution smoke (§11 gate-2 of contract) ─────────────────────────────
// new-Function harness: wrap scene-init.js in a function call, inject recording
// DI stubs, invoke createSceneInit and then initScene(). The expected failure
// is a TypeError on null T (T.WebGLRenderer) — assert the setters reached
// before that throw were called.
// Documented gap: WRITE paths in sloppy-mode new-Function differ from strict;
// ESLint no-undef on src/scene-init.js (gate-3) compensates.
test('smoke: createSceneInit factory returns expected 4-key surface', () => {
// Source-scan variant (no DOM/WebGL needed): verify factory return statement.
// Find the LAST return { ... } in the file — that is the factory's return.
const src = fs.readFileSync(sceneInitJs, 'utf8');
const lastReturnIdx = src.lastIndexOf('return {');
assert.ok(lastReturnIdx >= 0, 'createSceneInit must have a return { ... } statement');
const ret = src.slice(lastReturnIdx, src.indexOf('}', lastReturnIdx) + 1);
for (const name of ['initScene', 'buildBoard', '_bgUnmountStyle', '_bcSyncMode']) {
assert.ok(ret.includes(name), `factory return must include ${name}`);
}
// Must NOT export private helpers
for (const priv of ['_bgLoadSettings', '_applyBgTheme', '_bgRebuild', '_bgMountStyle']) {
assert.ok(!ret.includes(priv), `factory return must NOT include private ${priv}`);
}
});
test('smoke: new-Function harness — factory construction + null-canvas early guard', () => {
const rawSrc = fs.readFileSync(sceneInitJs, 'utf8');
// Strip ES module syntax for new Function
let src = rawSrc
.replace(/^\/\*\s*global[^*]*\*\//m, '')
.replace(/^import\s+\{[^}]+\}\s+from\s+['"][^'"]+['"]\s*;?/gm, '')
.replace(/^export\s+/gm, '');
// Recording setter stubs
const called = new Set();
function makeSetter(name) { return (...a) => called.add(name); }
function makeGetter(val) { return () => val; }
// Minimal fake T that lets initScene advance past wrap creation before dying
const fakeT = null; // null T causes first `new T.WebGLRenderer(...)` to throw
const di = {
// Constants (scene-init needs these to not ReferenceError at DI destructure)
K: 1, NW: 0.5, NH: 0.5, ND: 1, NFRETS: 24,
S_BASE: 0, S_GAP: 0.1,
FOG_START: 10, FOG_END: 100, BASE_VFOV: 45,
HWY_LANE_STRIPE_ODD_HEX: '#111', HWY_LANE_STRIPE_EVEN_HEX: '#222',
CHORD_BOX_TEAL_HEX: '#0ff', CHORD_BOX_TEAL_DARK_HEX: '#0aa',
CHORD_BOX_FILL_GRAD_ALPHA: 0.5,
ARPEGGIO_BOX_BLUE_HEX: '#00f', ARPEGGIO_BOX_BLUE_DARK_HEX: '#008',
ARPEGGIO_RIM_BLUE_HEX: '#0af', FRET_LABEL_GOLD_HEX: '#fa0',
CHORD_BOX_EDGE_ALPHA: 0.8,
BG_DEFAULTS: {}, BG_STYLES: [], PALETTES: {},
IM_TECH_CAP: 64, IM_STRUM_CAP: 64, MAX_RENDER_STRINGS: 7,
SLIDE_RIBBON_SAMPLES: 8, SLIDE_RIBBON_INDICES_ARR: new Uint16Array(0),
DEFAULT_GEM_GRADIENTS: [], INLAY_LABEL_FRETS: [],
SPARK_N: 256, _ND_TTL_MS: 1000, _ND_TIME_EPS: 0.01,
FRET_WIRE_HIT_HEX: '#fff', FRET_WIRE_HIT_EMISSIVE: 1,
FRET_WIRE_IDLE_HEX: '#888', FRET_WIRE_IDLE_OP: 0.5,
ACCENT_RIM_BASE_EMISSIVE: 0.5,
ACCENT_HALO_OP_NEAR: 0.8, ACCENT_HALO_OP_MID: 0.5, ACCENT_HALO_OP_FAR: 0.2,
ACCENT_HALO_XY_INNER: 0.1, ACCENT_HALO_XY_MID: 0.2, ACCENT_HALO_XY_OUTER: 0.3,
ACCENT_HALO_Z_INNER: 0, ACCENT_HALO_Z_MID: 0.1, ACCENT_HALO_Z_OUTER: 0.2,
STR_THICK: 0.02, FRET_BOW_DZ: 0.1, FRET_TUBE_RADIUS: 0.02,
FRET_TUBE_SEG: 4, FRET_TUBE_RADIAL: 4,
FRET_METALNESS: 0.5, FRET_ROUGHNESS: 0.5, FRET_EMISSIVE: 0.1,
AHEAD: 4, TS: 1, DOTS: [], DDOTS: [],
// Fn-refs (no-ops)
sY: () => 0, fretLabelScaleForFret: () => 1,
pool: () => ({ reset(){}, get(){ return {}; } }),
txtMat: () => ({}),
palmMuteXSpriteMat: () => ({}), fretHandMuteXSpriteMat: () => ({}),
_applyCinematic: () => {}, _h3dHexOrDefault: (h) => h || '#000',
_bgPanelKey: () => '', _bgReadSetting: () => null,
_bgGetAnalyser: () => null, _bgBackgroundColors: () => [],
_bgHighwayColors: () => [], _bgSubscribe: () => (() => {}),
_bgHasStored: () => false, _bgMemFallback: () => null,
_venueSwapPlateIfNeeded: () => {}, _darkenInt: (v) => v,
_lightenInt: (v) => v, _h3dHexToInt: () => 0,
boardSpanX: () => 10, _bcCreateController: () => ({}),
canvasSize: () => ({ w: 800, h: 600 }),
applySize: () => {}, fxInit: () => {},
_disposeOpenStringPitchSprites: () => {},
// Stable refs
_ownedSharedMats: [], _ownedSharedGeos: [],
_imPMTechAlphaArr: new Float32Array(64), _imFHTechAlphaArr: new Float32Array(64),
_imPMXFillAlphaArr: new Float32Array(64), _imPMXLinesAlphaArr: new Float32Array(64),
_imFHXFillAlphaArr: new Float32Array(64), _imFHXLinesAlphaArr: new Float32Array(64),
fretLastActiveTime: new Float32Array(25), _fwHitGlow: new Float32Array(25),
_customPalette: null, _outlinePalette: null, _tuningLabelSprites: {},
// Getters
getH3dFretUniform: makeGetter(false),
getHighwayCanvas: makeGetter(null), // null canvas → initScene returns false immediately
getInstanceId: makeGetter(1),
getLeftyCached: makeGetter(false), getNStr: makeGetter(6),
getActivePalette: makeGetter([0xff0000, 0x00ff00, 0x0000ff, 0xffff00, 0xff00ff, 0x00ffff]),
getTextSize: makeGetter(1), getGlowMul: makeGetter(1),
getVibrancyIdleOp: makeGetter(0.5), getVibrancyProjOp: makeGetter(0.3),
getBgReactiveOptOut: makeGetter(false),
getVenueSceneOverride: makeGetter(null),
getVibrancy: makeGetter(0.5),
};
// Add recording setter stubs for every setter the DI might declare
// (use a Proxy-like approach: any property access on di returns a no-op setter)
const diProxy = new Proxy(di, {
get(target, prop) {
if (prop in target) return target[prop];
// Unknown getter → return a no-op getter
if (typeof prop === 'string' && prop.startsWith('get')) return makeGetter(null);
// Unknown setter → return a recording stub
if (typeof prop === 'string' && prop.startsWith('set')) return makeSetter(prop);
return undefined;
}
});
let factory;
try {
// eslint-disable-next-line no-new-func
const fn = new Function('di', src + '\n return createSceneInit(di);');
factory = fn(diProxy);
} catch (e) {
assert.fail(`createSceneInit construction threw unexpectedly: ${e.message}`);
}
assert.ok(factory && typeof factory.initScene === 'function',
'factory must return object with initScene');
// Call initScene() — with null canvas it returns false immediately (no T used)
// This tests the early-guard path: canvas null → immediate return false
const result = factory.initScene();
assert.strictEqual(result, false,
'initScene with null canvas must return false (early guard)');
// The smoke test documents the WebGL gap: we cannot reach T.WebGLRenderer
// without a real canvas. Source-scan kill tests above cover the setter-call
// paths that require WebGL. ESLint no-undef is the compensating layer for
// WRITE paths in sloppy-mode new-Function.
});
test('_getHighwayCanvasAlias does not appear in scene-init.js (dead alias removed in cut-17)', () => {
const src = fs.readFileSync(sceneInitJs, 'utf8');
assert.ok(!src.includes('_getHighwayCanvasAlias'),
'_getHighwayCanvasAlias must not appear in scene-init.js — dead alias was removed in cut-17');
});
-508
View File
@@ -1,508 +0,0 @@
// h3d-carve-10: Regression coverage for score FX (notedetect ≥1.13) extracted
// into plugins/highway_3d/src/score-fx.js.
//
// Strategy (source-level, matching the rest of tests/js/):
// - gut-audit every export + internal path via source-scan
// - class-killers: fxTeardown listener removal, _fxGen increment
// - verbatim-declaration of the no-re-entry-guard behavior in fxInit
// - wiring-correspondence guard for createScoreFx({...}) in screen.js
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 SCORE_FX_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'score-fx.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
const scoreFxSrc = fs.readFileSync(SCORE_FX_JS, 'utf8');
// ── Module shape ────────────────────────────────────────────────────────────
test('score-fx.js exports createScoreFx', () => {
assert.match(scoreFxSrc, /export\s+function\s+createScoreFx\s*\(/,
'score-fx.js must export createScoreFx');
});
test('createScoreFx returns all four expected exports', () => {
assert.match(
scoreFxSrc,
/return\s*\{[^}]*fxInit[^}]*fxTeardown[^}]*fxSpawnPop\s*:\s*_fxSpawnPop[^}]*drawScoreFx[^}]*\}/,
'factory must return { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx, ... }',
);
});
// ── DI rewires — all 7 beyond-subst changes ─────────────────────────────────
test('_fxSpawnPop uses getNdFrameNowMs() DI getter, not _ndFrameNowMs directly', () => {
assert.match(
scoreFxSrc,
/getNdFrameNowMs\(\)\s*\|\|\s*performance\.now\(\)/,
'_fxSpawnPop must call getNdFrameNowMs() for the current-time sample',
);
// Sever: replace getNdFrameNowMs() with a literal → nowMs is always a
// stale value and the TTL dedup fires wrong. The test goes RED.
assert.doesNotMatch(
scoreFxSrc,
/const\s+nowMs\s*=\s*_ndFrameNowMs\s*\|\|/,
'_ndFrameNowMs must not appear bare in the module (must use DI getter)',
);
});
test('drawScoreFx aliases cam and _probe from DI getters at function entry', () => {
assert.match(
scoreFxSrc,
/const\s+cam\s*=\s*getCam\(\)/,
'drawScoreFx must alias cam via getCam()',
);
assert.match(
scoreFxSrc,
/const\s+_probe\s*=\s*getProbe\(\)/,
'drawScoreFx must alias _probe via getProbe()',
);
});
test('drawScoreFx calls getNStr() and getCurX() inline (no bare nStr / curX)', () => {
assert.match(
scoreFxSrc,
/sY\(\s*getNStr\(\)\s*-\s*1\s*\)/,
'drawScoreFx must call getNStr() for the string-count probe',
);
assert.match(
scoreFxSrc,
/_probe\.set\(\s*getCurX\(\)/,
'drawScoreFx must call getCurX() for the strike-line X coordinate',
);
});
test('fxInit uses getHighwayCanvas() inside the event closure, not a captured ref', () => {
assert.match(
scoreFxSrc,
/getHighwayCanvas\(\)\s*\|\|\s*!t\.parentElement\.contains\(\s*getHighwayCanvas\(\)\s*\)/,
'fxInit closure must call getHighwayCanvas() per-event for panel scoping',
);
// Sever: bake in a captured ref → panel isolation breaks on canvas swap.
assert.doesNotMatch(
scoreFxSrc,
/const\s+hc\s*=\s*getHighwayCanvas\(\)[\s\S]*?t\.parentElement\.contains\(\s*hc\s*\)/,
'fxInit must not capture highwayCanvas into a local (must re-read per event)',
);
});
// ── fxInit gut-audit ────────────────────────────────────────────────────────
test('fxInit calls _fxResolvePalette before registering the listener', () => {
assert.match(
scoreFxSrc,
/function\s+fxInit[\s\S]*?_fxResolvePalette\(\)[\s\S]*?window\.addEventListener\('notedetect:fx'/,
'fxInit must resolve the palette before arming the event listener',
);
});
test('fxInit registers the notedetect:fx listener on window', () => {
assert.match(
scoreFxSrc,
/window\.addEventListener\(\s*'notedetect:fx'\s*,\s*_fxOnFx\s*\)/,
'fxInit must register _fxOnFx on window for notedetect:fx',
);
});
test('fxInit registers the notedetect:skin skin-change listener via feedBack bus', () => {
assert.match(
scoreFxSrc,
/window\.feedBack\.on\(\s*'notedetect:skin'\s*,\s*_fxOnSkin\s*\)/,
'fxInit must register _fxOnSkin for skin changes',
);
});
// CLASS-KILLER: no re-entry guard (VERBATIM-PRESERVED from original screen.js)
// The original init block had no guard. Adding one without a corresponding
// double-register test is a silent behavior change. This test asserts the
// absence so any accidental addition turns RED.
test('fxInit has no re-entry guard (verbatim-preserved: original had none)', () => {
assert.doesNotMatch(
scoreFxSrc,
/function\s+fxInit[\s\S]{0,80}if\s*\(\s*_fxOnFx\s*\)\s*return/,
'fxInit must not have a re-entry guard (verbatim from original; see cut-10 dispatch)',
);
});
// ── fxTeardown gut-audit + class-killers ────────────────────────────────────
// CLASS-KILLER (a): sever fxTeardown listener removal → listeners live on.
// If window.removeEventListener call is deleted, this test fails because
// the pattern is gone. Combined with the _fxGen increment test below these
// two together cover the complete teardown contract.
test('fxTeardown removes the notedetect:fx listener (class-killer: sever → RED)', () => {
assert.match(
scoreFxSrc,
/window\.removeEventListener\(\s*'notedetect:fx'\s*,\s*_fxOnFx\s*\)/,
'fxTeardown must remove the notedetect:fx listener from window',
);
});
test('fxTeardown removes the notedetect:skin skin listener via feedBack bus', () => {
assert.match(
scoreFxSrc,
/window\.feedBack\.off\(\s*'notedetect:skin'\s*,\s*_fxOnSkin\s*\)/,
'fxTeardown must remove the skin listener to avoid palette updates after teardown',
);
});
test('fxTeardown resets all pop and burst slots to inactive', () => {
assert.match(
scoreFxSrc,
/for\s*\(\s*const\s+p\s+of\s+_fxPops\s*\)\s*p\.active\s*=\s*false/,
'fxTeardown must deactivate every pop slot',
);
assert.match(
scoreFxSrc,
/for\s*\(\s*const\s+b\s+of\s+_fxBursts\s*\)\s*b\.active\s*=\s*false/,
'fxTeardown must deactivate every burst slot',
);
});
test('fxTeardown clears _fxSeen and resets ring/break anchors', () => {
assert.match(
scoreFxSrc,
/_fxSeen\.clear\(\)/,
'fxTeardown must clear the pop-dedup map',
);
assert.match(
scoreFxSrc,
/_fxRingMs\s*=\s*_fxBreakMs\s*=\s*-1e9/,
'fxTeardown must reset ring and break anchors to -1e9',
);
});
// CLASS-KILLER (b): sever _fxGen increment → deferred window-copy fallback
// fires after teardown and can arm state on the next fresh init. If the
// increment line is deleted this test fails.
test('fxTeardown increments _fxGen to invalidate deferred window-copy fallbacks (class-killer: sever → RED)', () => {
assert.match(
scoreFxSrc,
/_fxGen\+\+/,
'fxTeardown must increment _fxGen so setTimeout callbacks from the prior session bail',
);
});
test('fxTeardown resets _fxElemSeen to a fresh WeakSet', () => {
assert.match(
scoreFxSrc,
/_fxElemSeen\s*=\s*new\s+WeakSet\(\)/,
'fxTeardown must reset _fxElemSeen so stale details from the prior session are not re-deduplicated',
);
});
// ── _fxHandle gut-audit ─────────────────────────────────────────────────────
test('_fxHandle deduplicates on reference equality with _fxLastFxDetail', () => {
assert.match(
scoreFxSrc,
/if\s*\(\s*d\s*===\s*_fxLastFxDetail\s*\)\s*return/,
'_fxHandle must bail on duplicate detail reference',
);
});
test('_fxHandle routes milestone → burst, multiplier-up → ring, streakBreak → break', () => {
assert.match(
scoreFxSrc,
/d\.fxType\s*===\s*'milestone'[\s\S]*?_fxSpawnBurst\(\s*nowMs\s*\)/,
'milestone must spawn a burst',
);
assert.match(
scoreFxSrc,
/d\.fxType\s*===\s*'multiplier'\s*&&\s*d\.mult\s*>\s*\(\s*d\.prevMult\s*\|\|\s*1\s*\)[\s\S]*?_fxRingMs\s*=\s*nowMs/,
'multiplier tier-up must arm the ring pulse',
);
assert.match(
scoreFxSrc,
/d\.fxType\s*===\s*'streakBreak'[\s\S]*?_fxBreakMs\s*=\s*nowMs/,
'streakBreak must arm the flicker',
);
});
// ── drawScoreFx gut-audit ───────────────────────────────────────────────────
test('drawScoreFx returns early when cam or probe is falsy', () => {
assert.match(
scoreFxSrc,
/if\s*\(\s*!cam\s*\|\|\s*!_probe\s*\)\s*return/,
'drawScoreFx must early-exit when cam or probe is unavailable',
);
});
test('drawScoreFx early-exits when all effects are expired', () => {
assert.match(
scoreFxSrc,
/if\s*\(\s*!anyPop\s*&&\s*!anyBurst\s*&&\s*ringAge\s*>=\s*600\s*&&\s*breakAge\s*>=\s*350\s*\)\s*return/,
'drawScoreFx must skip canvas work entirely when all effect TTLs are expired',
);
});
test('drawScoreFx TTL-prunes _fxSeen each frame', () => {
assert.match(
scoreFxSrc,
/for\s*\(\s*const\s+\[k\s*,\s*exp\]\s+of\s+_fxSeen\s*\)[\s\S]*?_fxSeen\.delete\(\s*k\s*\)/,
'drawScoreFx must prune expired pop-dedup keys every frame',
);
});
test('drawScoreFx renders streak-break flicker as a fill-rect wash', () => {
assert.match(
scoreFxSrc,
/breakAge\s*<\s*350[\s\S]*?ctx\.fillRect\(\s*0\s*,\s*0\s*,\s*W\s*,\s*H\s*\)/,
'streak-break flicker must fill the entire panel',
);
});
test('drawScoreFx computes strike-line center via _probe.project(cam)', () => {
assert.match(
scoreFxSrc,
/_probe\.set\(\s*getCurX\(\)\s*,\s*fretMidY\s*,\s*0\s*\)[\s\S]*?_probe\.project\(\s*cam\s*\)/,
'strike-line center must be projected from getCurX() via _probe',
);
});
test('drawScoreFx renders multiplier ring-pulse as an expanding arc', () => {
assert.match(
scoreFxSrc,
/ringAge\s*<\s*600[\s\S]*?ctx\.arc\([\s\S]*?Math\.PI\s*\*\s*2\s*\)/,
'ring-pulse must draw an expanding arc when active',
);
});
test('drawScoreFx renders burst particles with gravity', () => {
assert.match(
scoreFxSrc,
/b\.vy\[j\]\s*\+=\s*0\.08/,
'burst particles must apply gravity each frame',
);
});
test('drawScoreFx renders "+N" pops that rise and fade over their lifetime', () => {
assert.match(
scoreFxSrc,
/sy2\s*=[\s\S]*?H\s*-\s*t\s*\*\s*30/,
'pops must rise (subtract t*30) over their lifetime',
);
assert.match(
scoreFxSrc,
/ctx\.globalAlpha\s*=\s*t\s*<\s*0\.4\s*\?\s*1\s*:\s*1\s*-\s*\(\s*t\s*-\s*0\.4\s*\)\s*\/\s*0\.6/,
'pops must fade over the back half of their lifetime',
);
});
// ── _fxSpawnPop gut-audit ───────────────────────────────────────────────────
test('_fxSpawnPop deduplicates via _fxSeen.has(popKey)', () => {
assert.match(
scoreFxSrc,
/_fxSeen\.has\(\s*popKey\s*\)/,
'_fxSpawnPop must reject duplicate popKeys via _fxSeen',
);
});
test('_fxSpawnPop sets a 4-second expiry in _fxSeen', () => {
assert.match(
scoreFxSrc,
/_fxSeen\.set\(\s*popKey\s*,\s*nowMs\s*\+\s*4000\s*\)/,
'_fxSpawnPop must register the popKey with a 4s TTL',
);
});
test('_fxSpawnPop fills the first inactive slot and returns early (pool-full = drop)', () => {
assert.match(
scoreFxSrc,
/if\s*\(\s*p\.active\s*\)\s*continue[\s\S]*?p\.active\s*=\s*true/,
'_fxSpawnPop must scan for an inactive slot and claim it',
);
});
// ── screen.js wiring ────────────────────────────────────────────────────────
test('screen.js imports createScoreFx from src/score-fx.js', () => {
assert.match(
src,
/import\s*\{\s*createScoreFx\s*\}\s*from\s*'\.\/src\/score-fx\.js'/,
'screen.js must import createScoreFx',
);
});
test('screen.js destroys original K-section state block (no _fxPops let/const in IIFE scope)', () => {
// The state block is now inside the factory. If any line leaked back into
// screen.js this assertion fails.
assert.doesNotMatch(
src,
/const\s+_fxPops\s*=/,
'_fxPops must not be declared in screen.js after extraction',
);
assert.doesNotMatch(
src,
/const\s+_fxBursts\s*=/,
'_fxBursts must not be declared in screen.js after extraction',
);
});
test('screen.js init callsite replaced with fxInit()', () => {
assert.match(
src,
/fxInit\(\)\s*;/,
'screen.js init path must call fxInit()',
);
assert.doesNotMatch(
src,
/window\.addEventListener\(\s*'notedetect:fx'/,
'screen.js must not directly register notedetect:fx after extraction',
);
});
test('screen.js teardown callsite replaced with fxTeardown()', () => {
assert.match(
src,
/fxTeardown\(\)\s*;/,
'screen.js teardown path must call fxTeardown()',
);
// The _fxOnFx removal now lives in fxTeardown — not in screen.js.
assert.doesNotMatch(
src,
/window\.removeEventListener\(\s*'notedetect:fx'/,
'screen.js must not directly unregister notedetect:fx after extraction',
);
});
// ── Wiring-correspondence guard (extends cut-9 pattern) ─────────────────────
// Structural source-scan: every entry in createScoreFx({...}) must satisfy
// its naming-correspondence class. Kills swaps like (getCam: () => _probe).
// PINNED_RENAMES is empty — all 6 getters are plain convention, sY is shorthand.
test('createScoreFx({...}) wiring has correct naming correspondence (no param swaps)', () => {
const PINNED_RENAMES = {};
const ANCHOR = 'const { fxInit, fxTeardown, fxSpawnPop: _fxSpawnPop, drawScoreFx, fxClearSeen } = createScoreFx({';
const callStart = src.indexOf(ANCHOR);
assert.ok(callStart >= 0, 'createScoreFx 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, 'createScoreFx 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 >= 7, `expected at least 7 entries, got ${entries.length}`);
const violations = [];
for (const entry of entries) {
if (!entry.includes(':')) continue; // shorthand
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;
}
if (key.startsWith('set')) {
const expectedStem = key[3].toLowerCase() + key.slice(4);
const m = value.match(/^\(v\)\s*=>\s*\{\s*_?(\w+)\s*=\s*v\s*;\s*\}$/);
if (!m) {
violations.push(`${key}: setter value '${value}' does not match (v) => { [_]var = v; }`);
continue;
}
if (m[1] !== expectedStem) {
violations.push(`${key}: setter assigns 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, [], 'createScoreFx wiring violations found');
});
// ── TDZ regression guard (fix/h3d-viz-init-fallback) ────────────────────────
// Input that FAILS at broken tip: calling window.feedBackViz_highway_3d()
// (i.e. createFactory()) throws ReferenceError: Cannot access 'sY' before
// initialization — const sY was declared 371 lines AFTER createScoreFx({...,
// sY}), putting sY in the temporal dead zone on every factory invocation.
// Result: viz picker fell back to 2D immediately; THREE.js never requested.
//
// Guard: ESLint no-use-before-define (variables:true, functions:false) scoped
// over screen.js and src/ — statically flags ANY const/let used before its
// declaration in the factory, catching the entire class not just this pair.
// At the broken tip this rule errors on sY; after the fix it is clean.
// Prefer this semantic gate over source-scan byte-offset comparisons, which
// miss TDZ bugs invisible to regex (this was the FOURTH such break).
test('no-use-before-define gate is clean on highway_3d screen.js and src/ (fix/h3d-viz-init-fallback)', () => {
const { execSync } = require('node:child_process');
const repoRoot = path.join(__dirname, '..', '..');
let stdout;
try {
stdout = execSync(
'npx --yes eslint@9.39.4 --format json plugins/highway_3d/screen.js plugins/highway_3d/src/',
{ cwd: repoRoot, encoding: 'utf8' },
);
} catch (err) {
// eslint exits non-zero when errors exist; output is still on stdout.
stdout = err.stdout || '';
}
const results = JSON.parse(stdout);
const tdzErrors = [];
for (const file of results) {
for (const msg of file.messages) {
if (msg.ruleId === 'no-use-before-define' && msg.severity === 2) {
tdzErrors.push(`${path.relative(repoRoot, file.filePath)}:${msg.line}${msg.message}`);
}
}
}
assert.deepEqual(
tdzErrors,
[],
'no-use-before-define errors found in highway_3d — a const/let is used before its ' +
'declaration in the factory (real TDZ risk). At the broken tip, sY was used in ' +
'createScoreFx({..., sY}) 371 lines before its declaration.\n' + tdzErrors.join('\n'),
);
});
+6 -10
View File
@@ -15,13 +15,9 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
// h3d-carve-14: V-section moved to note-renderer.js
const NOTE_RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
const _noteSrc = fs.readFileSync(NOTE_RENDERER_JS, 'utf8');
test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes', () => { test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/const\s+checkSrc\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*?stSet\.add\(/, /const\s+checkSrc\s*=\s*\([^)]*\)\s*=>\s*\{[\s\S]*?stSet\.add\(/,
@@ -29,16 +25,16 @@ test('a _slideTargetSet pre-pass builds the suppressed-gem set from bundle.notes
); );
assert.match( assert.match(
src, src,
/if\s*\(\s*stSet\.size\s*>\s*0\s*\)\s*(?:_slideTargetSet\s*=\s*stSet|setSlideTargetSet\s*\(\s*stSet\s*\))/, /if\s*\(\s*stSet\.size\s*>\s*0\s*\)\s*_slideTargetSet\s*=\s*stSet/,
'_slideTargetSet must be assigned from the pre-pass result', '_slideTargetSet must be assigned from the pre-pass result',
); );
}); });
test('_isSlideTgt is derived from _slideTargetSet membership', () => { test('_isSlideTgt is derived from _slideTargetSet membership', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/_isSlideTgt\s*=\s*!!\(\s*(?:_slideTargetSet|getSlideTargetSet\(\))\s*&&\s*(?:_slideTargetSet|getSlideTargetSet\(\))\.has\(/, /_isSlideTgt\s*=\s*!!\(\s*_slideTargetSet\s*&&\s*_slideTargetSet\.has\(/,
'_isSlideTgt must test _slideTargetSet membership', '_isSlideTgt must test _slideTargetSet membership',
); );
}); });
@@ -46,7 +42,7 @@ test('_isSlideTgt is derived from _slideTargetSet membership', () => {
test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => { test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => {
// drawNote(n, now, openX, skipLabel, skipBody, ...) — _isSlideTgt sits in // drawNote(n, now, openX, skipLabel, skipBody, ...) — _isSlideTgt sits in
// the 5th (skipBody) position so the gem body is suppressed. // the 5th (skipBody) position so the gem body is suppressed.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/drawNote\(\s*n\s*,\s*now\s*,\s*singleOpenX\s*,\s*skipLabel\s*,\s*_isSlideTgt\s*,/, /drawNote\(\s*n\s*,\s*now\s*,\s*singleOpenX\s*,\s*skipLabel\s*,\s*_isSlideTgt\s*,/,
@@ -57,7 +53,7 @@ test('_isSlideTgt is threaded into drawNote as the skipBody argument', () => {
test('the sustain trail renders for all notes, including skipBody slide targets', () => { test('the sustain trail renders for all notes, including skipBody slide targets', () => {
// The trail block must stay outside the !skipBody gem gate so suppressed // The trail block must stay outside the !skipBody gem gate so suppressed
// slide-target gems still show their slide trail. // slide-target gems still show their slide trail.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + _noteSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
src, src,
/Rendered for ALL notes with sustain, including skipBody=true/, /Rendered for ALL notes with sustain, including skipBody=true/,
@@ -20,7 +20,6 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js'); const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
// Brace-balanced extraction (same helper shape as highway_note_state.test.js). // Brace-balanced extraction (same helper shape as highway_note_state.test.js).
function extractBlock(src, signature) { function extractBlock(src, signature) {
@@ -61,7 +60,7 @@ test('core _makeBundle exposes isPlaying derived from the chart-clock anchor', (
}); });
test('smoothNow returns raw and re-anchors when the host reports not playing', () => { test('smoothNow returns raw and re-anchors when the host reports not playing', () => {
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + fs.readFileSync(RENDERER_JS, 'utf8'); const src = fs.readFileSync(highway3dJs, 'utf8');
const fn = extractBlock(src, 'function smoothNow(bundle)'); const fn = extractBlock(src, 'function smoothNow(bundle)');
// Strict === false so downlevel hosts (isPlaying undefined) fall through // Strict === false so downlevel hosts (isPlaying undefined) fall through
// to the existing staleness-based interpolation cap. // to the existing staleness-based interpolation cap.
@@ -70,16 +69,14 @@ test('smoothNow returns raw and re-anchors when the host reports not playing', (
// The pause branch re-anchors the clock state and returns the raw sample // The pause branch re-anchors the clock state and returns the raw sample
// (no forward extrapolation). // (no forward extrapolation).
// h3d-carve-15: bare assignments → DI setter calls in renderer.js
const branch = fn.slice(guardIdx); const branch = fn.slice(guardIdx);
assert.match(branch, /setClkAudioT\s*\(\s*raw\s*\)/, 'pause branch must re-anchor _clkAudioT to raw'); assert.match(branch, /_clkAudioT\s*=\s*raw/, 'pause branch must re-anchor _clkAudioT to raw');
assert.match(branch, /setClkPerf\s*\(\s*p\s*\)/, 'pause branch must re-anchor _clkPerf to now'); assert.match(branch, /_clkPerf\s*=\s*p/, 'pause branch must re-anchor _clkPerf to now');
assert.match(branch, /setFrameNow\s*\([^)]+\)/, 'pause branch must call setFrameNow (return raw)'); assert.match(branch, /return\s*\(\s*_frameNow\s*=\s*raw\s*\)/, 'pause branch must return raw');
// The pause gate must come before the new-sample re-anchor / interpolation // The pause gate must come before the new-sample re-anchor / interpolation
// path so a frozen clock never extrapolates forward. // path so a frozen clock never extrapolates forward.
// h3d-carve-15: _clkAudioT accessed via getClkAudioT() getter in renderer.js const newSampleIdx = fn.search(/if\s*\(\s*raw\s*!==\s*_clkAudioT\s*\)/);
const newSampleIdx = fn.search(/if\s*\(\s*raw\s*!==\s*(?:_clkAudioT|getClkAudioT\s*\(\s*\))\s*\)/);
assert.ok(newSampleIdx !== -1, 'smoothNow new-sample branch not found'); assert.ok(newSampleIdx !== -1, 'smoothNow new-sample branch not found');
assert.ok(guardIdx < newSampleIdx, 'isPlaying pause gate must precede the interpolation path'); assert.ok(guardIdx < newSampleIdx, 'isPlaying pause gate must precede the interpolation path');
}); });
-293
View File
@@ -1,293 +0,0 @@
// 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');
});
+8 -17
View File
@@ -5,10 +5,6 @@
// stops using additive blending, or bumps the bloom renderOrder above the // stops using additive blending, or bumps the bloom renderOrder above the
// core rail (16) would silently regress or invert the effect. // core rail (16) would silently regress or invert the effect.
// //
// Since h3d-carve-1, _makeGaussTex is defined in src/geometry.js and
// imported into screen.js; the call site (_bloomGaussTex = _makeGaussTex(...))
// remains in screen.js.
//
// Source-level only — same strategy as the other tests/js/ files. // Source-level only — same strategy as the other tests/js/ files.
const { test } = require('node:test'); const { test } = require('node:test');
@@ -17,17 +13,14 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const GEOMETRY_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'geometry.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
test('a gaussian DataTexture helper (_makeGaussTex) drives the bloom falloff', () => { test('a gaussian DataTexture helper (_makeGaussTex) drives the bloom falloff', () => {
const geo = fs.readFileSync(GEOMETRY_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match( assert.match(
geo, src,
/export\s+function\s+_makeGaussTex\s*\(/, /function\s+_makeGaussTex\s*\(/,
'_makeGaussTex must be exported from geometry.js to build the bloom gaussian texture', '_makeGaussTex must exist to build the bloom gaussian texture',
); );
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
assert.match( assert.match(
src, src,
/_bloomGaussTex\s*=\s*_makeGaussTex\(/, /_bloomGaussTex\s*=\s*_makeGaussTex\(/,
@@ -36,11 +29,10 @@ test('a gaussian DataTexture helper (_makeGaussTex) drives the bloom falloff', (
}); });
test('the bloom rail material uses additive blending', () => { test('the bloom rail material uses additive blending', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-16: DI form uses _mSusRailBloomBase (local alias)
assert.match( assert.match(
src, src,
/(?:_m)?SusRailBloomBase\s*=\s*new\s+T\.MeshBasicMaterial\(\{[\s\S]*?blending:\s*T\.AdditiveBlending[\s\S]*?\}\)/, /mSusRailBloomBase\s*=\s*new\s+T\.MeshBasicMaterial\(\{[\s\S]*?blending:\s*T\.AdditiveBlending[\s\S]*?\}\)/,
'mSusRailBloomBase must blend additively so it brightens what is behind it', 'mSusRailBloomBase must blend additively so it brightens what is behind it',
); );
}); });
@@ -48,11 +40,10 @@ test('the bloom rail material uses additive blending', () => {
test('the bloom pool seeds meshes at renderOrder 4, behind the core rail (5)', () => { test('the bloom pool seeds meshes at renderOrder 4, behind the core rail (5)', () => {
// renderOrder 4 keeps the bloom behind the core sustain rail (5) so the // renderOrder 4 keeps the bloom behind the core sustain rail (5) so the
// glow reads as a trail rather than occluding the rail. // glow reads as a trail rather than occluding the rail.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-16: DI form uses _pSusRailBloom (local alias); pool first arg may be a getter call
assert.match( assert.match(
src, src,
/(?:_p)?SusRailBloom\s*=\s*pool\([\s\S]{0,100}?,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*4\s*;[\s\S]*?\}\s*\)/, /pSusRailBloom\s*=\s*pool\([^)]*,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*4\s*;[\s\S]*?\}\s*\)/,
'pSusRailBloom pool must seed meshes with renderOrder = 4', 'pSusRailBloom pool must seed meshes with renderOrder = 4',
); );
}); });
+6 -11
View File
@@ -13,27 +13,23 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
test('sustain rails are gated on multi-note chords with a known box width within AHEAD', () => { test('sustain rails are gated on multi-note chords with a known box width within AHEAD', () => {
// Each chord in a sequence (including repeats) draws a rail from its onset // Each chord in a sequence (including repeats) draws a rail from its onset
// to the next chord's onset, chaining together to cover the full handshape // to the next chord's onset, chaining together to cover the full handshape
// duration visually. Single notes have no chord frame to anchor a rail to. // duration visually. Single notes have no chord frame to anchor a rail to.
// h3d-carve-15: sustain-rail block moved to renderer.js const src = fs.readFileSync(SCREEN_JS, 'utf8');
const rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
assert.match( assert.match(
rendererSrc, src,
/if\s*\(\s*chShape\.size\s*>\s*1\s*&&\s*chordOpenBoxW\s*!=\s*null\s*&&\s*chDt\s*<\s*AHEAD\s*\)/, /if\s*\(\s*chShape\.size\s*>\s*1\s*&&\s*chordOpenBoxW\s*!=\s*null\s*&&\s*chDt\s*<\s*AHEAD\s*\)/,
'sustain-rail block must stay gated on chShape.size > 1, chordOpenBoxW and chDt < AHEAD', 'sustain-rail block must stay gated on chShape.size > 1, chordOpenBoxW and chDt < AHEAD',
); );
}); });
test('sustain rails pick arpeggio color for arpeggio frames, teal otherwise', () => { test('sustain rails pick arpeggio color for arpeggio frames, teal otherwise', () => {
// h3d-carve-15: rail color expression moved to renderer.js const src = fs.readFileSync(SCREEN_JS, 'utf8');
const rendererSrc = fs.readFileSync(RENDERER_JS, 'utf8');
assert.match( assert.match(
rendererSrc, src,
/chordHighwayLavenderArpVisual\s*\?\s*ARPEGGIO_RIM_BLUE_HEX\s*:\s*CHORD_BOX_TEAL_HEX/, /chordHighwayLavenderArpVisual\s*\?\s*ARPEGGIO_RIM_BLUE_HEX\s*:\s*CHORD_BOX_TEAL_HEX/,
'rail color must select ARPEGGIO_RIM_BLUE_HEX for arpeggio frames and CHORD_BOX_TEAL_HEX for chords', 'rail color must select ARPEGGIO_RIM_BLUE_HEX for arpeggio frames and CHORD_BOX_TEAL_HEX for chords',
); );
@@ -44,11 +40,10 @@ test('sustain-rail pool meshes keep renderOrder 5 so strings (7) stay on top', (
// of the rail. Chord frame edges are Z-proportional [48,698] and note gems // of the rail. Chord frame edges are Z-proportional [48,698] and note gems
// are Z-proportional [50,700], so the flat seed value does not conflict — // are Z-proportional [50,700], so the flat seed value does not conflict —
// emitSusStrip() assigns its own Z-proportional RO per segment at draw time. // emitSusStrip() assigns its own Z-proportional RO per segment at draw time.
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8'); const src = fs.readFileSync(SCREEN_JS, 'utf8');
// h3d-carve-16: DI form uses _pSusRail (local alias); pool first arg may be a getter call
assert.match( assert.match(
src, src,
/(?:_p)?SusRail\s*=\s*pool\([\s\S]{0,100}?,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*5\s*;[\s\S]*?\}\s*\)/, /pSusRail\s*=\s*pool\([^)]*,\s*\(\)\s*=>\s*\{[\s\S]*?m\.renderOrder\s*=\s*5\s*;[\s\S]*?\}\s*\)/,
'pSusRail pool must seed meshes with renderOrder = 5', 'pSusRail pool must seed meshes with renderOrder = 5',
); );
}); });
-92
View File
@@ -1,92 +0,0 @@
// Class-killer for src/three-loader.js — h3d-carve-2.
//
// The loader is a memoised async import with a CDN fallback. Runtime calls
// import(url) which only resolves against a live server, so the behavioural
// contract is pinned by source-scan regex that name the concrete mutation
// each assertion catches.
//
// Source-scan is sufficient here: the loader's correctness depends entirely
// on its static structure (memoisation guard, T-assignment, CDN fallback)
// rather than on runtime values — the same pattern used for all other
// source-level tests in this suite.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const LOADER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'three-loader.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _loader;
function loader() {
if (!_loader) _loader = fs.readFileSync(LOADER_JS, 'utf8');
return _loader;
}
test('loadThree is exported from three-loader.js', () => {
// Mutation: rename or remove the export → the import in screen.js throws.
assert.match(loader(), /export\s+function\s+loadThree\s*\(\s*\)/,
'loadThree must be exported so screen.js can import it');
});
test('T is exported as a live let-binding from three-loader.js', () => {
// Mutation: export const T — const bindings cannot be reassigned from within
// the module, so T = mod in loadThree() would throw a TypeError.
assert.match(loader(), /export\s+let\s+T\s*=\s*null/,
'T must be exported as a mutable let-binding so the .then handler can update it');
});
test('loadThree assigns T = mod in both the primary and CDN .then handlers', () => {
// Mutation 1: remove all T = mod — T stays null forever; T.WebGLRenderer throws.
// Two assignments exist: primary .then and CDN fallback .then.
// Mutation 2 (Toby r1): `const T = mod` inside .then bodies — count=2 still
// matches, but shadows the module-level export; live-binding T stays null forever.
const matches = loader().match(/T\s*=\s*mod\s*;/g) || [];
assert.ok(matches.length >= 2,
'T = mod must appear in both the primary and CDN .then handlers (found ' + matches.length + ')');
assert.doesNotMatch(loader(), /(?:const|let|var)\s+T\s*=\s*mod/,
'T = mod must be a bare assignment, not a declaration that shadows the live-binding export');
});
test('loadThree memoises the promise — returns existing promise on repeated calls', () => {
// Mutation: remove the !threeLoadPromise guard — a new promise is kicked off on
// every call, racing against previous loads and resetting T on each resolution.
assert.match(loader(), /if\s*\(\s*!threeLoadPromise\s*\)/,
'memoisation guard must prevent duplicate simultaneous import() calls');
});
test('loadThree has a CDN fallback for the local vendor copy', () => {
// Mutation: remove the .catch(() => import(THREE_CDN) chain — offline / mis-routed
// deploys that fail to reach /static/vendor/three/ get no fallback and throw.
assert.match(loader(), /\.catch\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*THREE_CDN\s*\)/,
'CDN fallback must kick in when the local vendor copy is unavailable');
});
test('loadThree resets threeLoadPromise to null on total failure', () => {
// Mutation: remove threeLoadPromise = null in the final catch — a failed load
// permanently memoises the rejected promise; a page reload recovers but a plugin
// re-init (same session) can never retry the import.
assert.match(loader(), /threeLoadPromise\s*=\s*null/,
'failed load must reset threeLoadPromise so a retry can succeed');
});
test('screen.js imports loadThree and T from three-loader.js', () => {
// Confirms the import line is present and the live-binding is wired.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(src,
/import\s+\{\s*loadThree\s*,\s*T\s*\}\s*from\s*['"]\.\/src\/three-loader\.js['"]/,
'screen.js must import both loadThree and T from the loader module');
});
test('screen.js IIFE no longer declares local let T or let threeLoadPromise', () => {
// Mutation: leave the old local declarations in place — the IIFE's local T shadows
// the live-binding import so T is always null inside the factory.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
// Strip the import lines at the top of the file before searching the IIFE body.
const iife = src.replace(/^import\s+.*?\n/gm, '');
assert.doesNotMatch(iife, /\blet\s+T\s*=\s*null\s*;/,
'IIFE must not redeclare T — the local shadow would defeat the live-binding export');
assert.doesNotMatch(iife, /\blet\s+threeLoadPromise\s*=\s*null\s*;/,
'IIFE must not redeclare threeLoadPromise — it belongs to the loader module now');
});
-234
View File
@@ -1,234 +0,0 @@
// Class-killer tests for src/utils.js — h3d-carve-3.
//
// Pure functions are evaluated by stripping 'export' keywords and wrapping
// the source in a new Function so the whole module runs in a controlled
// scope. _ssActive and _ssIsCanvasFocused use `window` (live global), so
// they are covered by source-scan only. Screen.js wiring is verified by
// scanning the import declaration.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'utils.js');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
let _utils;
function utils() { if (!_utils) _utils = fs.readFileSync(UTILS_JS, 'utf8'); return _utils; }
// Evaluate all pure exports in a CommonJS-compatible scope.
// Strips 'export' keywords; _ssActive/_ssIsCanvasFocused access window
// which is not available here — skip them in the pure eval.
let _fns;
function fns() {
if (_fns) return _fns;
const src = utils().replace(/^export\s+/gm, '');
// provide a minimal window stub so _ssActive / _ssIsCanvasFocused don't
// throw at declaration time (they only READ window inside their bodies).
const factory = new Function('window', src + `
return {
_h3dHexToInt, _clampByteI, _darkenInt, _lightenInt,
resolveStringCount,
_NOTE_NAMES_SHARP,
_BASE_OPEN_MIDI_BASS4, _BASE_OPEN_MIDI_BASS5,
_BASE_OPEN_MIDI_GUITAR6, _BASE_OPEN_MIDI_GUITAR7, _BASE_OPEN_MIDI_GUITAR8,
_baseOpenStringMidis, _midiToPitchLabel, _openStringPitchLabelsForTuning,
_ssActive, _ssIsCanvasFocused,
};`);
_fns = factory({ feedBackSplitscreen: null });
return _fns;
}
// ── _h3dHexToInt ──────────────────────────────────────────────────────────────
test('_h3dHexToInt: 6-char hex parses to integer', () => {
// Mutation: remove parseInt → returns NaN; renderer sees non-numeric color.
assert.strictEqual(fns()._h3dHexToInt('#ff0000'), 0xff0000);
assert.strictEqual(fns()._h3dHexToInt('00ff00'), 0x00ff00);
});
test('_h3dHexToInt: 3-char shorthand expands to 6', () => {
// Mutation: remove the t[0]+t[0] expansion → 'fff' parses as 0x0fff (wrong).
assert.strictEqual(fns()._h3dHexToInt('#fff'), 0xffffff,
'#fff must expand to #ffffff, not 0x0fff');
assert.strictEqual(fns()._h3dHexToInt('abc'), 0xaabbcc);
});
test('_h3dHexToInt: invalid input returns null', () => {
// Mutation: remove the regex guard → parseInt('gg0000', 16) returns NaN, not null.
assert.strictEqual(fns()._h3dHexToInt('gg0000'), null);
assert.strictEqual(fns()._h3dHexToInt(null), null);
assert.strictEqual(fns()._h3dHexToInt(42), null);
});
// ── _clampByteI ───────────────────────────────────────────────────────────────
test('_clampByteI: clamps below 0', () => {
// Mutation: remove < 0 guard → returns negative value; bitshift corrupts high channels.
assert.strictEqual(fns()._clampByteI(-1), 0);
assert.strictEqual(fns()._clampByteI(-999), 0);
});
test('_clampByteI: clamps above 255 and rounds', () => {
// Mutation: remove > 255 guard or Math.round → oversaturated channels / float bits.
assert.strictEqual(fns()._clampByteI(256), 255);
assert.strictEqual(fns()._clampByteI(127.7), 128,
'must round 127.7 to 128, not truncate to 127');
});
// ── _darkenInt / _lightenInt ──────────────────────────────────────────────────
test('_darkenInt: halves each channel of pure white', () => {
// Mutation: remove _clampByteI call → channel value not clamped; bitshift carries.
const result = fns()._darkenInt(0xffffff, 0.5);
const r = (result >> 16) & 0xff, g = (result >> 8) & 0xff, b = result & 0xff;
assert.strictEqual(r, 128, 'red channel must be Math.round(255*0.5)=128');
assert.strictEqual(g, 128);
assert.strictEqual(b, 128);
});
test('_lightenInt: mixing pure black toward white by 1.0 yields white', () => {
// Mutation: swap r+(255-r)*t → r*(1-t) → wrong formula for lightening.
assert.strictEqual(fns()._lightenInt(0x000000, 1.0), 0xffffff,
'black mixed t=1 toward white must equal 0xffffff');
});
// ── resolveStringCount ────────────────────────────────────────────────────────
test('resolveStringCount: uses bundle.stringCount and clamps to maxStrings', () => {
// Mutation: remove Math.min → returns 8 for a chart that declares 8 strings;
// per-string material arrays index OOB.
assert.strictEqual(fns().resolveStringCount({ stringCount: 8 }, 6), 6,
'stringCount=8 exceeds maxStrings=6; must clamp');
assert.strictEqual(fns().resolveStringCount({ stringCount: 4 }, 6), 4);
});
test('resolveStringCount: maxStrings param is authoritative, not a hardcoded 6', () => {
// Mutation: re-hardcode maxStrings=6 inside utils.js → resolveStringCount({stringCount:7}, 7)
// returns 6; 7th-string notes are silently never drawn and no test fails.
assert.strictEqual(fns().resolveStringCount({ stringCount: 7 }, 7), 7,
'maxStrings=7 must allow stringCount=7 through without clamping to a hardcoded 6');
assert.strictEqual(fns().resolveStringCount({ stringCount: 10 }, 7), 7,
'stringCount exceeding maxStrings must clamp to maxStrings, not 6');
});
test('resolveStringCount: falls back to 4 for bass arrangement', () => {
// Mutation: remove /bass/i test → bass charts get 6 strings; 5th/6th string
// material slots are undefined and T.WebGLRenderer calls throw.
assert.strictEqual(
fns().resolveStringCount({ songInfo: { arrangement: 'Bass' } }, 6),
4,
'arrangement containing "Bass" must fall back to 4 strings');
});
test('resolveStringCount: defaults to NSTR=6 when bundle has no string info', () => {
assert.strictEqual(fns().resolveStringCount({}, 6), 6);
});
// ── _NOTE_NAMES_SHARP ─────────────────────────────────────────────────────────
test('_NOTE_NAMES_SHARP: 12 entries, correct spot values', () => {
// Mutation: remove 'F#' → midiToPitchLabel returns 'G' for F# notes; tuner wrong.
const n = fns()._NOTE_NAMES_SHARP;
assert.strictEqual(n.length, 12, 'chromatic octave must have 12 entries');
assert.strictEqual(n[0], 'C');
assert.strictEqual(n[6], 'F#', 'index 6 must be F#');
assert.strictEqual(n[11], 'B');
});
// ── _baseOpenStringMidis ──────────────────────────────────────────────────────
test('_baseOpenStringMidis: 4-string bass returns standard bass4 tuning', () => {
// Mutation: remove sc===4 && isBass branch → returns guitar4 slice instead.
const result = fns()._baseOpenStringMidis(4, 'Bass');
assert.deepStrictEqual(result, [28, 33, 38, 43],
'4-string bass must use standard E-A-D-G bass open-string MIDIs');
});
test('_baseOpenStringMidis: 6-string default returns guitar6 tuning', () => {
const result = fns()._baseOpenStringMidis(6, 'Lead');
assert.deepStrictEqual(result, [40, 45, 50, 55, 59, 64]);
});
// ── _midiToPitchLabel ─────────────────────────────────────────────────────────
test('_midiToPitchLabel: MIDI 60 = C4, MIDI 69 = A4', () => {
// Mutation: remove "- 1" from octave calc → C4 becomes C5.
assert.strictEqual(fns()._midiToPitchLabel(60), 'C4',
'MIDI 60 is middle C (C4); the "- 1" octave offset is required');
assert.strictEqual(fns()._midiToPitchLabel(69), 'A4',
'MIDI 69 is concert A (A4)');
});
// ── _openStringPitchLabelsForTuning ──────────────────────────────────────────
test('_openStringPitchLabelsForTuning: standard guitar in E returns correct labels', () => {
// Smoke: 6 zero-offset strings with guitar6 MIDI base. maxStrings=6 passed explicitly
// (mirrors the delegator in screen.js which supplies MAX_RENDER_STRINGS).
const labels = fns()._openStringPitchLabelsForTuning(
{ tuning: [0, 0, 0, 0, 0, 0], capo: 0, stringCount: 6 },
{ arrangement: 'Lead' },
6,
6, // maxStrings
);
assert.deepStrictEqual(labels, ['E2', 'A2', 'D3', 'G3', 'B3', 'E4'],
'standard guitar open-string labels must be E2-A2-D3-G3-B3-E4');
});
// ── _ssActive / _ssIsCanvasFocused — source-scan ─────────────────────────────
test('_ssActive reads window.feedBackSplitscreen live', () => {
// Mutation: capture window.feedBackSplitscreen at module scope → old reference
// used after splitscreen enables mid-session; ss.isActive() never true.
assert.match(utils(), /window\.feedBackSplitscreen/,
'_ssActive must read window.feedBackSplitscreen without caching it');
});
test('_ssIsCanvasFocused calls _ssActive', () => {
// Mutation: inline _ssActive logic → test becomes two separate paths to maintain;
// one diverges silently.
assert.match(utils(), /_ssIsCanvasFocused[\s\S]{1,200}_ssActive\(\)/,
'_ssIsCanvasFocused must delegate to _ssActive()');
});
// ── screen.js wiring ──────────────────────────────────────────────────────────
test('screen.js imports all Cut 3 utils from src/utils.js', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(src,
/import\s+\{[^}]*_ssActive[^}]*\}\s+from\s+['"]\.\/src\/utils\.js['"]/,
'screen.js must import _ssActive (and other utils) from ./src/utils.js');
assert.match(src, /_resolveStringCountBase/,
'resolveStringCount must be imported with an alias so the delegator can shadow it');
assert.match(src, /_h3dHexToInt/,
'_h3dHexToInt must appear in the utils.js import line');
});
test('screen.js delegator passes MAX_RENDER_STRINGS to resolveStringCount', () => {
// Mutation: delegator omits MAX_RENDER_STRINGS → resolveStringCount called with
// maxStrings=undefined; Math.min(sc, undefined)=NaN; string count is always NaN.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(src, /_resolveStringCountBase\s*\(.*MAX_RENDER_STRINGS/,
'delegator must supply MAX_RENDER_STRINGS so palette growth is auto-respected');
});
test('screen.js delegator passes MAX_RENDER_STRINGS to _openStringPitchLabelsForTuning', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.match(src, /_openStringPitchLabelsForTuningBase\s*\(.*MAX_RENDER_STRINGS/,
'delegator must forward MAX_RENDER_STRINGS as the maxStrings argument');
});
test('screen.js IIFE no longer declares the moved symbols', () => {
// Strip import lines first so we only scan the IIFE body.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const iife = src.replace(/^import\s+.*\n/gm, '');
assert.doesNotMatch(iife, /function\s+_h3dHexToInt\s*\(/,
'IIFE must not redefine _h3dHexToInt');
assert.doesNotMatch(iife, /function\s+_ssActive\s*\(/,
'IIFE must not redefine _ssActive');
assert.doesNotMatch(iife, /const\s+_NOTE_NAMES_SHARP\s*=/,
'IIFE must not redefine _NOTE_NAMES_SHARP');
assert.doesNotMatch(iife, /function\s+resolveStringCount\s*\(/,
'IIFE must not redefine resolveStringCount');
});
+10 -20
View File
@@ -21,10 +21,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const CAMERA_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'camera.js'); // h3d-carve-9 const src = fs.readFileSync(SCREEN_JS, 'utf8');
const SCENE_INIT_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
const src = fs.readFileSync(SCREEN_JS, 'utf8') + '\n' + fs.readFileSync(SCENE_INIT_JS, 'utf8');
const cameraSrc = fs.readFileSync(CAMERA_JS, 'utf8'); // h3d-carve-9: effectiveVfov/camUpdate moved here
// ── Constants ──────────────────────────────────────────────────────────────── // ── Constants ────────────────────────────────────────────────────────────────
@@ -56,18 +53,16 @@ test('the Hor+ start-aspect and min-vfov defaults exist', () => {
test('effectiveVfov returns the base fov when the bridge is off/absent', () => { test('effectiveVfov returns the base fov when the bridge is off/absent', () => {
// The disabled / malformed-input guard returns `base` before any Hor+ math, // The disabled / malformed-input guard returns `base` before any Hor+ math,
// so normal panes are unaffected when __h3dAspectTune is missing or off. // so normal panes are unaffected when __h3dAspectTune is missing or off.
// h3d-carve-9: effectiveVfov moved to src/camera.js — retarget to cameraSrc.
assert.match( assert.match(
cameraSrc, src,
/function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/, /function\s+effectiveVfov\s*\(\s*aspect\s*,\s*tune\s*\)\s*\{[\s\S]*?if\s*\(\s*!tune\s*\|\|\s*!tune\.enabled[\s\S]*?return\s+base\s*;/,
'effectiveVfov must short-circuit to the base fov when disabled', 'effectiveVfov must short-circuit to the base fov when disabled',
); );
}); });
test('effectiveVfov is a no-op at/under the start aspect', () => { test('effectiveVfov is a no-op at/under the start aspect', () => {
// h3d-carve-9: effectiveVfov moved to src/camera.js — retarget to cameraSrc.
assert.match( assert.match(
cameraSrc, src,
/if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/, /if\s*\(\s*aspect\s*<=\s*start\s*\)\s*return\s+base\s*;/,
'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)', 'effectiveVfov must return base when aspect <= start (no-op for normal/2x2 panes)',
); );
@@ -126,11 +121,10 @@ test('applySize caches the pane aspect for camUpdate', () => {
}); });
test('camUpdate resolves a per-pane tune and respects splitOnly', () => { test('camUpdate resolves a per-pane tune and respects splitOnly', () => {
// h3d-carve-9: camUpdate moved to src/camera.js; resolveTuneFor is DI-renamed.
assert.match( assert.match(
cameraSrc, src,
/const\s+_aspTune\s*=\s*resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/, /const\s+_aspTune\s*=\s*_resolveTuneFor\(\s*_paneKey\s*\)\s*;[\s\S]*?_aspTune\.splitOnly\s*&&\s*!_ssActive\(\)/,
'camUpdate must resolve the tune per pane via resolveTuneFor(_paneKey) and gate splitOnly', 'camUpdate must resolve the tune per pane via _resolveTuneFor(_paneKey) and gate splitOnly',
); );
}); });
@@ -176,8 +170,7 @@ test('a Target select and pane registry drive the per-pane picker', () => {
'the panel must build a Target <select>'); 'the panel must build a Target <select>');
assert.match(src, /function\s+_aspectRegisterPane\s*\(/, assert.match(src, /function\s+_aspectRegisterPane\s*\(/,
'_aspectRegisterPane must record live panes for the picker'); '_aspectRegisterPane must record live panes for the picker');
// h3d-carve-9: camUpdate moved to camera.js; fn-ref DI-renamed (no underscore). assert.match(src, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*_aspectRegisterPane\(\s*_paneKey\s*\)/,
assert.match(cameraSrc, /if\s*\(\s*window\.__h3dAspectPanelOpen\s*\)\s*aspectRegisterPane\(\s*_paneKey\s*\)/,
'camUpdate must register its pane only while the tuner panel is open'); 'camUpdate must register its pane only while the tuner panel is open');
}); });
@@ -190,11 +183,9 @@ test('panes are keyed by arrangement (stable across songs, no split-API dep)', (
/function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/, /function\s+_aspectPaneKey\s*\(\s*arrangement\s*,\s*uid\s*\)[\s\S]*?'arr:'\s*\+\s*a[\s\S]*?'pane:'\s*\+\s*uid/,
'_aspectPaneKey must prefer arr:<name> and fall back to pane:<uid>', '_aspectPaneKey must prefer arr:<name> and fall back to pane:<uid>',
); );
// h3d-carve-9: camUpdate moved to camera.js; fn-ref DI-renamed (no underscore),
// _paneUid replaced by getPaneUid() accessor call.
assert.match( assert.match(
cameraSrc, src,
/const\s+_paneKey\s*=\s*aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*getPaneUid\(\)\s*\)\s*;/, /const\s+_paneKey\s*=\s*_aspectPaneKey\(\s*[\s\S]*?songInfo[\s\S]*?arrangement\s*,\s*_paneUid\s*\)\s*;/,
'camUpdate must key the pane by arrangement (with the uid fallback)', 'camUpdate must key the pane by arrangement (with the uid fallback)',
); );
}); });
@@ -295,9 +286,8 @@ test('the panel has a dismiss (close) control', () => {
test('camUpdate only writes cam.fov when it actually changes', () => { test('camUpdate only writes cam.fov when it actually changes', () => {
// Guarding the write avoids a per-frame updateProjectionMatrix on a steady // Guarding the write avoids a per-frame updateProjectionMatrix on a steady
// pane and keeps the disabled path free. // pane and keeps the disabled path free.
// h3d-carve-9: camUpdate moved to src/camera.js — retarget to cameraSrc.
assert.match( assert.match(
cameraSrc, src,
/Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/, /Math\.abs\(\s*_vfov\s*-\s*cam\.fov\s*\)\s*>\s*1e-4[\s\S]*?cam\.fov\s*=\s*_vfov\s*;[\s\S]*?cam\.updateProjectionMatrix\(\)/,
'camUpdate must guard the cam.fov write behind a change check', 'camUpdate must guard the cam.fov write behind a change check',
); );
+1 -2
View File
@@ -28,8 +28,7 @@ function loadFn(file, name) {
// R3c: the PURE geometry/label primitives were carved out of highway.js into // R3c: the PURE geometry/label primitives were carved out of highway.js into
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved. // static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const bnvNormalizedPoints = loadFn('static/js/highway-geometry.js', 'bnvNormalizedPoints'); const bnvNormalizedPoints = loadFn('static/js/highway-geometry.js', 'bnvNormalizedPoints');
// h3d-carve-14: bnvSampleAt moved to note-renderer.js (private helper inside createNoteRenderer) const bnvSampleAt = loadFn('plugins/highway_3d/screen.js', 'bnvSampleAt');
const bnvSampleAt = loadFn('plugins/highway_3d/src/note-renderer.js', 'bnvSampleAt');
// ── bnvNormalizedPoints (2D) ───────────────────────────────────────────────── // ── bnvNormalizedPoints (2D) ─────────────────────────────────────────────────
+1 -6
View File
@@ -331,12 +331,7 @@ test('default 2D draw path prefers the staged views (drawNotes/drawChords/drawSu
}); });
test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => { test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => {
// h3d-carve-3: _openStringPitchLabelsForTuning (let tuning / let cap) moved to src/utils.js const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
const UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'utils.js');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8')
+ '\n' + fs.readFileSync(RENDERER_JS, 'utf8')
+ '\n' + fs.readFileSync(UTILS_JS, 'utf8');
assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/, assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/,
'label derivation reads a well-formed bundle.tuning first, songInfo otherwise'); 'label derivation reads a well-formed bundle.tuning first, songInfo otherwise');
assert.match(src, /let cap = bundle\.capo;/, assert.match(src, /let cap = bundle\.capo;/,
+1 -2
View File
@@ -28,8 +28,7 @@ function loadFn(file, name) {
// R3c: the PURE geometry/label primitives were carved out of highway.js into // R3c: the PURE geometry/label primitives were carved out of highway.js into
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved. // static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const labels2D = loadFn('static/js/highway-geometry.js', 'chordHarmonyLabels'); const labels2D = loadFn('static/js/highway-geometry.js', 'chordHarmonyLabels');
// h3d-carve-14: chordHarmonyLabels moved to note-renderer.js const labels3D = loadFn('plugins/highway_3d/screen.js', 'chordHarmonyLabels');
const labels3D = loadFn('plugins/highway_3d/src/note-renderer.js', 'chordHarmonyLabels');
for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) { for (const [name, fn] of [['2D', labels2D], ['3D', labels3D]]) {
test(`chordHarmonyLabels (${name}) surfaces rn + voicing + caged + guideTones`, () => { test(`chordHarmonyLabels (${name}) surfaces rn + voicing + caged + guideTones`, () => {
+2 -11
View File
@@ -17,11 +17,6 @@ const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
// cannot import per-instance state without two panels sharing it. // cannot import per-instance state without two panels sharing it.
const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js'); const primitivesJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-state-primitives.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const RENDERER_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'renderer.js');
// h3d-carve-14: V-section (drawNote) moved to note-renderer.js; tests that
// pin its patterns must now also search note-renderer.js.
const _h3dNoteRendererJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'note-renderer.js');
const _h3dNoteRendererSrc = fs.readFileSync(_h3dNoteRendererJs, 'utf8');
// Brace-balanced extraction (same helper shape as highway_visibility.test.js). // Brace-balanced extraction (same helper shape as highway_visibility.test.js).
function extractBlock(src, signature) { function extractBlock(src, signature) {
@@ -143,10 +138,7 @@ test('default 2D renderer threads note state into drawNote / drawSustains / chor
}); });
test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => { test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with the provider verdict', () => {
// h3d-carve-14: _ndGetNoteState captured in update() (screen.js); _showHit const src = fs.readFileSync(highway3dJs, 'utf8');
// and its drawNote body are now in note-renderer.js — search both.
// h3d-carve-15: _ndGetNoteState / _ndHasProvider captures moved to renderer.js update()
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + _h3dNoteRendererSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
assert.match(src, /_ndGetNoteState\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteState\s*===\s*['"]function['"]\)\s*\?\s*bundle\.getNoteState\s*:\s*null/, 'update() must capture bundle.getNoteState into _ndGetNoteState'); assert.match(src, /_ndGetNoteState\s*=\s*\(bundle\s*&&\s*typeof\s+bundle\.getNoteState\s*===\s*['"]function['"]\)\s*\?\s*bundle\.getNoteState\s*:\s*null/, 'update() must capture bundle.getNoteState into _ndGetNoteState');
// Provider verdict wins: miss => not _showHit; otherwise provider state // Provider verdict wins: miss => not _showHit; otherwise provider state
// or the legacy fallback (`hit`) plus the pre-hit ghost window preview. // or the legacy fallback (`hit`) plus the pre-hit ghost window preview.
@@ -154,8 +146,7 @@ test('3D highway captures bundle.getNoteState and overrides legacy hit/miss with
}); });
test('3D highway captures _ndHasProvider via bundle.getNoteStateProvider (feedBack#254)', () => { test('3D highway captures _ndHasProvider via bundle.getNoteStateProvider (feedBack#254)', () => {
// h3d-carve-15: _ndGetNoteState / _ndHasProvider captures moved to renderer.js update() const src = fs.readFileSync(highway3dJs, 'utf8');
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + _h3dNoteRendererSrc + '\n' + fs.readFileSync(RENDERER_JS, 'utf8');
// Detect-mode behavior — verdict-window cull extension, chord-frame // Detect-mode behavior — verdict-window cull extension, chord-frame
// hold floor, and the smart drawNote cull — must be gated on a real // hold floor, and the smart drawNote cull — must be gated on a real
// provider being registered, not on the always-present bundle. // provider being registered, not on the always-present bundle.
+2 -5
View File
@@ -13,7 +13,6 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js'); const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const sceneInitJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
// The highway string-colour manager was carved out of app.js into its own // The highway string-colour manager was carved out of app.js into its own
// module (R3a). // module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js'); const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
@@ -79,8 +78,7 @@ test('2D public API exposes getStringColors / setStringColors', () => {
// ── 3D highway (plugins/highway_3d/screen.js) ───────────────────────────── // ── 3D highway (plugins/highway_3d/screen.js) ─────────────────────────────
test('3D adds a custom palette path + h3dBgSetStringColors setter', () => { test('3D adds a custom palette path + h3dBgSetStringColors setter', () => {
// _bgLoadSettings (lines 89-90) moved to scene-init.js in h3d-carve-16 — combine both const src = fs.readFileSync(highway3dJs, 'utf8');
const src = fs.readFileSync(highway3dJs, 'utf8') + '\n' + fs.readFileSync(sceneInitJs, 'utf8');
assert.match(src, /window\.h3dBgSetStringColors\s*=/, 'window.h3dBgSetStringColors must be defined'); assert.match(src, /window\.h3dBgSetStringColors\s*=/, 'window.h3dBgSetStringColors must be defined');
assert.match(src, /_bgWriteGlobal\('customColors'/, 'setter must persist customColors'); assert.match(src, /_bgWriteGlobal\('customColors'/, 'setter must persist customColors');
assert.match(src, /_bgWriteGlobal\('palette',\s*'custom'\)/, "setter must flip palette to 'custom'"); assert.match(src, /_bgWriteGlobal\('palette',\s*'custom'\)/, "setter must flip palette to 'custom'");
@@ -93,8 +91,7 @@ test('3D adds a custom palette path + h3dBgSetStringColors setter', () => {
}); });
test('3D gem-body gradients follow the active palette (not hardcoded)', () => { test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
// _recolorGemGradients + _applyPaletteToMaterials moved to scene-init.js in h3d-carve-16 const src = fs.readFileSync(highway3dJs, 'utf8');
const src = fs.readFileSync(sceneInitJs, 'utf8');
// The gem bodies (strings 0..5) are a baked per-vertex gradient; a custom // The gem bodies (strings 0..5) are a baked per-vertex gradient; a custom
// palette must recolor them, else gems/sustain/vibrato heads stay stock. // palette must recolor them, else gems/sustain/vibrato heads stay stock.
assert.match(src, /function _recolorGemGradients\(\)/, '_recolorGemGradients must exist'); assert.match(src, /function _recolorGemGradients\(\)/, '_recolorGemGradients must exist');
+2 -3
View File
@@ -30,9 +30,8 @@ function loadFn(file, name) {
// static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved. // static/js/highway-geometry.js. Same bodies, byte-for-byte — only the file moved.
const fingerLabel2D = loadFn('static/js/highway-geometry.js', 'teachingFingerLabel'); const fingerLabel2D = loadFn('static/js/highway-geometry.js', 'teachingFingerLabel');
const degreeLabel2D = loadFn('static/js/highway-geometry.js', 'teachingDegreeLabel'); const degreeLabel2D = loadFn('static/js/highway-geometry.js', 'teachingDegreeLabel');
// h3d-carve-14: teachingFingerLabel/Degree moved to note-renderer.js const fingerLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingFingerLabel');
const fingerLabel3D = loadFn('plugins/highway_3d/src/note-renderer.js', 'teachingFingerLabel'); const degreeLabel3D = loadFn('plugins/highway_3d/screen.js', 'teachingDegreeLabel');
const degreeLabel3D = loadFn('plugins/highway_3d/src/note-renderer.js', 'teachingDegreeLabel');
const strumGroupBuckets = loadFn('static/js/highway-draw.js', 'strumGroupBuckets'); const strumGroupBuckets = loadFn('static/js/highway-draw.js', 'strumGroupBuckets');
// ── teachingFingerLabel (fg) ───────────────────────────────────────────────── // ── teachingFingerLabel (fg) ─────────────────────────────────────────────────
+16 -20
View File
@@ -10,7 +10,6 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js'); const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'); const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const sceneInitJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'src', 'scene-init.js'); // h3d-carve-16
// Brace-balanced extraction so a future method that grows guards or // Brace-balanced extraction so a future method that grows guards or
// nested blocks doesn't get truncated by a naive `[^}]*\}` regex. // nested blocks doesn't get truncated by a naive `[^}]*\}` regex.
@@ -132,10 +131,10 @@ test('api.setVisible accepts bool / null and re-emits inline', () => {
}); });
test('3D Highway subscribes to highway:visibility and toggles wrap on hide', () => { test('3D Highway subscribes to highway:visibility and toggles wrap on hide', () => {
// initScene moved to scene-init.js in h3d-carve-16; teardown stays in screen.js
const sceneInitSrc = fs.readFileSync(sceneInitJs, 'utf8');
const src = fs.readFileSync(highway3dJs, 'utf8'); const src = fs.readFileSync(highway3dJs, 'utf8');
const initSceneBlock = extractBlock(sceneInitSrc, 'function initScene()'); // Scope to lifecycle blocks so unrelated / commented mentions
// elsewhere in screen.js can't cause false positives.
const initSceneBlock = extractBlock(src, 'function initScene()');
const teardownBlock = extractBlock(src, 'function teardown()'); const teardownBlock = extractBlock(src, 'function teardown()');
// Listener registration with the documented event name (in init). // Listener registration with the documented event name (in init).
@@ -147,25 +146,23 @@ test('3D Highway subscribes to highway:visibility and toggles wrap on hide', ()
// Handler filters by canvas identity so splitscreen panels don't // Handler filters by canvas identity so splitscreen panels don't
// hide each other's overlays — every instance receives every event // hide each other's overlays — every instance receives every event
// on the shared feedBack bus, so this gate is essential. // on the shared feedBack bus, so this gate is essential.
// Accept DI-rewritten form (getHighwayCanvas()) as well as original (highwayCanvas) — h3d-carve-16 assert.match(
assert.ok( initSceneBlock,
/e\.detail\.canvas\s*!==\s*highwayCanvas/.test(initSceneBlock) || /e\.detail\.canvas\s*!==\s*highwayCanvas/,
/e\.detail\.canvas\s*!==\s*getHighwayCanvas\(\)/.test(initSceneBlock),
'handler must filter on event.detail.canvas !== highwayCanvas (splitscreen-safe)', 'handler must filter on event.detail.canvas !== highwayCanvas (splitscreen-safe)',
); );
// Handler toggles wrap/getWrap() display based on visible === false. // Handler toggles wrap.style.display based on visible === false.
assert.ok( assert.match(
/wrap\.style\.display\s*=\s*v\s*===\s*false\s*\?\s*['"]none['"]\s*:\s*['"]/.test(initSceneBlock) || initSceneBlock,
/getWrap\(\)\.style\.display\s*=\s*v\s*===\s*false\s*\?\s*['"]none['"]\s*:\s*['"]/.test(initSceneBlock), /wrap\.style\.display\s*=\s*v\s*===\s*false\s*\?\s*['"]none['"]\s*:\s*['"]['"]/,
'handler must hide the wrap when visible === false', 'handler must hide the wrap when visible === false',
); );
// Initial-sync on bind so renderers that mount while the canvas // Initial-sync on bind so renderers that mount while the canvas
// is already hidden (e.g. plugin loaded mid-splitscreen) don't // is already hidden (e.g. plugin loaded mid-splitscreen) don't
// leave the wrap stuck in the wrong state. // leave the wrap stuck in the wrong state.
// Accept DI-rewritten form (getHighwayCanvas()) as well as direct ref — h3d-carve-16 assert.match(
assert.ok( initSceneBlock,
/highwayCanvas\.offsetParent\s*!==\s*null/.test(initSceneBlock) || /highwayCanvas\.offsetParent\s*!==\s*null/,
/getHighwayCanvas\(\)\.offsetParent\s*!==\s*null/.test(initSceneBlock),
'initScene must compute initial visibility from local highwayCanvas (splitscreen-safe)', 'initScene must compute initial visibility from local highwayCanvas (splitscreen-safe)',
); );
// Subscribes to highway:canvas-replaced so the identity gate // Subscribes to highway:canvas-replaced so the identity gate
@@ -176,10 +173,9 @@ test('3D Highway subscribes to highway:visibility and toggles wrap on hide', ()
/window\.feedBack\.on\(\s*['"]highway:canvas-replaced['"]/, /window\.feedBack\.on\(\s*['"]highway:canvas-replaced['"]/,
'initScene must track canvas swaps so the visibility gate keeps matching', 'initScene must track canvas swaps so the visibility gate keeps matching',
); );
// Accept DI-rewritten form for canvas-replaced handler — h3d-carve-16 assert.match(
assert.ok( initSceneBlock,
/highwayCanvas\s*=\s*e\.detail\.newCanvas/.test(initSceneBlock) || /highwayCanvas\s*=\s*e\.detail\.newCanvas/,
/setHighwayCanvas\(\s*e\.detail\.newCanvas\s*\)/.test(initSceneBlock),
'canvas-replaced handler must update the local highwayCanvas reference', 'canvas-replaced handler must update the local highwayCanvas reference',
); );
// Teardown unbinds both listeners. // Teardown unbinds both listeners.
-403
View File
@@ -1,403 +0,0 @@
// Tests: vocals path + input_setup vocal-calibration handoff.
//
// Failure input for INSTRUMENTS absence: instrument id 'vocals' not in the map
// → wizard queue filters it out and the step is silently skipped.
// Failure input for facade absent: window.feedBack.vocalCalibration undefined
// → Calibrate click must NOT throw and must call advance (via setTimeout).
// Failure input for facade present: vocalCalibration.launch never called
// → missed the vocals branch, fell through to noteDetect path.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.join(__dirname, '..', '..');
const SCREEN_JS = path.join(ROOT, 'plugins', 'input_setup', 'screen.js');
const VOCALS_PATH = path.join(ROOT, 'data', 'progression', 'paths', 'vocals.json');
// ── helpers ───────────────────────────────────────────────────────────────────
function makeEl(tag, attrs) {
const el = {
tagName: (tag || 'DIV').toUpperCase(),
id: (attrs && attrs.id) || '',
className: '',
innerHTML: '',
textContent: '',
disabled: false,
hidden: false,
style: {},
children: [],
__handlers: {},
getAttribute(a) { return this[a] != null ? String(this[a]) : null; },
setAttribute(a, v) { this[a] = v; },
addEventListener(type, fn) {
(this.__handlers[type] || (this.__handlers[type] = [])).push(fn);
},
click() { (this.__handlers.click || []).forEach((fn) => fn()); },
_fire(type, arg) { (this.__handlers[type] || []).forEach((fn) => fn(arg)); },
appendChild(child) { this.children.push(child); return child; },
remove() {},
querySelector(sel) { return _qs(this, sel); },
querySelectorAll(sel) { const h = _qs(this, sel); return h ? [h] : []; },
get value() { return this._value || ''; },
set value(v) { this._value = v; },
};
// Rebuild querySelector list on innerHTML set via a simple data-attr stub.
// We patch innerHTML so the wizard's shell() and body replacements work.
let _html = '';
Object.defineProperty(el, 'innerHTML', {
get() { return _html; },
set(v) {
_html = v;
// Harvest known data-attrs the wizard queries after setting innerHTML.
el._namedChildren = {};
const RE = /data-([\w-]+)/g;
let m;
while ((m = RE.exec(v)) !== null) {
const key = m[1];
if (!el._namedChildren[key]) {
const child = makeEl('div', {});
child._attr = `data-${key}`;
child.className = '';
el._namedChildren[key] = child;
}
}
},
});
return el;
}
function _qs(el, sel) {
// Support [data-is-*] selectors used by the wizard.
const m = sel.match(/^\[data-([\w-]+)\]$/);
if (!m) return null;
if (el._namedChildren) return el._namedChildren[m[1]] || null;
return null;
}
function makeDocument(extraById) {
const byId = Object.assign({}, extraById || {});
return {
createElement(tag) { return makeEl(tag, {}); },
getElementById(id) { return byId[id] || null; },
body: makeEl('body', {}),
};
}
function loadScreenJs(windowOverrides) {
const code = fs.readFileSync(SCREEN_JS, 'utf8');
// The IIFE runs in the global scope — bare `document`, `setTimeout`, etc.
// must be top-level context properties, not nested under `window`.
const doc = (windowOverrides && windowOverrides.document) || makeDocument();
let _timeoutFn = null;
const timeoutImpl = (windowOverrides && windowOverrides.setTimeout)
|| function(fn, _ms) { fn(); };
const clearTimeoutImpl = (windowOverrides && windowOverrides.clearTimeout) || function(_id) {};
const ctx = {
window: Object.assign({
feedBack: {
capabilities: null,
midiInput: null,
vocalCalibration: undefined,
},
feedBackInputSetup: undefined,
noteDetect: undefined,
}, windowOverrides),
document: doc,
localStorage: (() => {
const store = {};
return {
getItem(k) { return store[k] != null ? store[k] : null; },
setItem(k, v) { store[k] = String(v); },
removeItem(k) { delete store[k]; },
};
})(),
fetch() { return Promise.resolve({ ok: true }); },
setTimeout: timeoutImpl,
clearTimeout: clearTimeoutImpl,
console,
};
vm.createContext(ctx);
vm.runInContext(code, ctx);
return ctx.window;
}
// ── 1. vocals.json shape ──────────────────────────────────────────────────────
test('vocals.json exists and has correct id, name, icon, and 5 levels', () => {
const raw = fs.readFileSync(VOCALS_PATH, 'utf8');
const json = JSON.parse(raw);
assert.equal(json.id, 'vocals');
assert.equal(json.name, 'Vocals');
assert.ok(json.icon, 'icon field must be present');
assert.equal(typeof json.order, 'number');
assert.equal(json.levels.length, 5);
// Every challenge id must be namespaced under vocals.*
for (const lvl of json.levels) {
assert.ok(Number.isInteger(lvl.level), 'level must be integer');
assert.ok(Array.isArray(lvl.challenges), 'challenges must be array');
for (const ch of lvl.challenges) {
assert.ok(ch.id.startsWith('vocals.'), `challenge id must start with vocals.: ${ch.id}`);
}
}
});
// ── 2. INSTRUMENTS map contains vocals ───────────────────────────────────────
test('screen.js INSTRUMENTS includes vocals with mode audio', () => {
const w = loadScreenJs();
// feedBackInputSetup.status should return a status object including vocals.
const status = w.feedBackInputSetup.status(['vocals']);
assert.ok('vocals' in status, 'vocals must appear in status output — meaning INSTRUMENTS has it');
assert.equal(status.vocals, 'needs-setup', 'fresh window → vocals needs-setup');
});
// ── 3. vocalCalibration facade absent → fallback notice, no throw ─────────────
test('renderAudioPanel for vocals falls back gracefully when vocalCalibration is absent', async () => {
// Override setTimeout to capture the delay+advance without waiting.
let timeoutFn = null;
const w = loadScreenJs({
feedBack: {
capabilities: null,
midiInput: null,
vocalCalibration: undefined, // absent
},
// setTimeout override — loadScreenJs reads it as the top-level ctx.setTimeout.
setTimeout(fn, _ms) { timeoutFn = fn; },
});
// We need a host element. wire a minimal one.
const hostEl = makeEl('div', {});
// We'll call mount() directly — it returns a promise that resolves to
// {completed, skipped}. Because capabilities is null, the domain owner
// registration is skipped, and the wizard just renders panels.
const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
// At this point renderAudioPanel has been called, which is async — wait a tick.
await new Promise((r) => setImmediate(r));
// The shell sets innerHTML which populates _namedChildren with data-is-cal.
const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal'];
assert.ok(calBtn, 'Calibrate button must be rendered for vocals audio panel');
// Click the Calibrate button — should NOT throw.
assert.doesNotThrow(() => calBtn.click());
// A setTimeout should have been scheduled (the fallback path), and the
// data-is-body should now hold the notice text.
assert.ok(timeoutFn, 'fallback must schedule a setTimeout to auto-advance');
// Fire the timeout — advances the wizard which resolves the promise.
timeoutFn();
const result = await mountPromise;
// Compare primitives to avoid cross-realm Array.prototype issues (VM context).
assert.equal(result.completed.length, 1, 'fallback must mark vocals completed');
assert.equal(result.completed[0], 'vocals', 'completed[0] must be vocals');
});
// ── 3b. double-click guard: second click must NOT queue a second advance ───────
// Failure input: vocalCalibration absent, instruments ['vocals','guitar'],
// two clicks on Calibrate before the 1800ms timer fires.
// Without guard: two timers queued → second fires on guitar panel → idx
// increments past guitar → finish() prematurely → guitar silently dropped.
test('double-click on fallback Calibrate does not double-advance the wizard', async () => {
const timers = [];
const w = loadScreenJs({
feedBack: { capabilities: null, midiInput: null, vocalCalibration: undefined },
setTimeout(fn, _ms) { timers.push(fn); },
});
// Two-instrument queue: vocals then guitar (guitar has noteDetect absent too,
// so it would also auto-advance — but we only care about the vocals panel here).
const hostEl = makeEl('div', {});
// Mount with ['vocals'] only so we can isolate the guard without needing a
// full multi-panel render (guitar panel is MIDI-unrelated complexity).
// The guard must prevent a second timer from being queued at all.
const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
await new Promise((r) => setImmediate(r));
const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal'];
assert.ok(calBtn, 'button must exist');
// Two rapid clicks.
calBtn.click();
calBtn.click();
// Only ONE timer must have been queued (button disabled after first click).
assert.equal(timers.length, 1, 'double-click must only queue one advance timer — input: two clicks before timer fires');
timers[0]();
const result = await mountPromise;
assert.equal(result.completed.length, 1);
assert.equal(result.completed[0], 'vocals');
});
// ── 3c. Calibrate-then-Skip stale-timer bug (Creed finding) ──────────────────
// Failure input: vocalCalibration absent, ['vocals'] queue, user clicks
// Calibrate (queues 1800ms timer) then clicks Skip before timer fires.
// Without fix: Skip calls advance('vocals', false) → wizard resolves, then
// the stale timer fires advance('vocals', true) → completed array mutated
// AFTER promise settled → result.completed gains 'vocals' retroactively;
// with a 2-instrument queue, idx is also double-incremented, dropping the
// next instrument from both lists.
// Fix: _activeCleanup = () => clearTimeout(_timerId) — advance() drains it.
test('Skip before fallback timer fires cancels the timer — no stale advance', async () => {
const timers = []; // collect scheduled timers without auto-firing
const cancelled = [];
const w = loadScreenJs({
feedBack: { capabilities: null, midiInput: null, vocalCalibration: undefined },
setTimeout(fn, _ms) { const id = timers.length; timers.push(fn); return id; },
clearTimeout(id) { cancelled.push(id); timers[id] = null; },
});
const hostEl = makeEl('div', {});
const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
await new Promise((r) => setImmediate(r));
const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal'];
assert.ok(calBtn, 'Calibrate button must exist');
// Step 1: click Calibrate — queues the 1800ms timer
calBtn.click();
assert.equal(timers.length, 1, 'one timer must be queued after Calibrate');
// Step 2: click Skip BEFORE the timer fires
const skipBtn = hostEl._namedChildren && hostEl._namedChildren['is-skip'];
assert.ok(skipBtn, 'Skip button must exist');
skipBtn.click();
// The promise resolves via Skip's advance('vocals', false)
const result = await mountPromise;
// vocals must be in skipped, NOT completed
assert.equal(result.skipped.length, 1, 'vocals must be skipped');
assert.equal(result.skipped[0], 'vocals');
assert.equal(result.completed.length, 0, 'completed must be empty after skip');
// The timer must have been cancelled — input that FAILS without the fix:
// if clearTimeout was NOT called, firing the stale timer now would mutate
// the completed array retrospectively.
assert.ok(cancelled.length > 0, 'clearTimeout must be called — stale timer must be cancelled');
// Verify: firing the (now-cancelled) timer is a no-op (timers[0] nulled out)
if (timers[0] !== null) {
// If the fix is missing, this would push 'vocals' into completed
timers[0]();
assert.equal(result.completed.length, 0, 'stale timer must not mutate completed after skip');
}
});
// ── 4. vocalCalibration facade present → launch() called, not noteDetect ──────
test('renderAudioPanel for vocals calls vocalCalibration.launch when facade present', async () => {
let launchArgs = null;
const facade = {
version: 1,
launch(args) { launchArgs = args; },
};
const w = loadScreenJs({
feedBack: {
capabilities: null,
midiInput: null,
vocalCalibration: facade,
},
});
let noteDetectCalled = false;
w.noteDetect = {
launchCalibration() { noteDetectCalled = true; },
};
const hostEl = makeEl('div', {});
const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
await new Promise((r) => setImmediate(r));
const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal'];
assert.ok(calBtn, 'Calibrate button must be rendered');
calBtn.click();
assert.ok(launchArgs, 'vocalCalibration.launch must have been called');
assert.equal(launchArgs.requester, 'input_setup');
assert.equal(typeof launchArgs.onDone, 'function');
assert.equal(typeof launchArgs.onCancel, 'function');
assert.equal(noteDetectCalled, false, 'noteDetect.launchCalibration must NOT be called for vocals');
// Simulate the facade calling onDone to settle the promise.
launchArgs.onDone({ latencyMs: 12, range: null, noiseFloorDb: null, micStatus: 'ok' });
const result = await mountPromise;
assert.equal(result.completed.length, 1);
assert.equal(result.completed[0], 'vocals');
});
// ── 5. _inputSetupRelaunch fallback includes 'vocals' (F2) ───────────────────
// Failure input: window._inputSetupRelaunch called when fetch('/api/progression')
// rejects — the fallback instrument list must include 'vocals' or Settings
// re-calibration silently skips it.
test('_inputSetupRelaunch fallback list includes vocals when API call fails', () => {
let launchedWith = null;
const w = loadScreenJs({
feedBack: { capabilities: null, midiInput: null, vocalCalibration: undefined },
// Reject the fetch to trigger the fallback path.
fetch() { return Promise.reject(new Error('network error')); },
});
// Patch launch() to capture the instruments without spinning up a full overlay.
w.feedBackInputSetup._captureNextLaunch = (list) => { launchedWith = list; };
// We can't easily intercept the internal `launch()` from outside the IIFE.
// Instead, verify the status API: after _inputSetupRelaunch is awaited,
// the fallback list is ['guitar','bass','vocals','keys','drums'] by reading
// the source directly (static code check via the status call on each).
// The definitive check: call status() for 'vocals' — it must be in INSTRUMENTS.
const status = w.feedBackInputSetup.status(['vocals', 'guitar', 'bass', 'keys', 'drums']);
assert.ok('vocals' in status, 'vocals must be in the status map — confirming INSTRUMENTS includes it');
// Structural read: _inputSetupRelaunch is a closure we cannot easily inspect,
// but the bug was the string literal ['guitar','bass','keys','drums'].
// Verify by reading the source file for the fixed literal.
const src = fs.readFileSync(SCREEN_JS, 'utf8');
assert.ok(
src.includes("'guitar', 'bass', 'vocals', 'keys', 'drums'") ||
src.includes("'guitar','bass','vocals','keys','drums'"),
"fallback list must include 'vocals' — input: /api/progression fetch failure"
);
});
// ── 6. button label keys off vocalCalibration for vocals (F3) ─────────────────
// Failure input: vocalCalibration present but noteDetect absent.
// Without fix: label reads 'Continue' (keyed off hasDetector=false).
// With fix: label reads 'Calibrate' (keyed off hasVocalCal=true).
test('Calibrate button shows Calibrate when vocalCalibration present and noteDetect absent', async () => {
const facade = { version: 1, launch(_args) {} };
const w = loadScreenJs({
feedBack: { capabilities: null, midiInput: null, vocalCalibration: facade },
// noteDetect is absent — without F3 fix, label would be 'Continue'
});
// noteDetect deliberately not set
const hostEl = makeEl('div', {});
w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] });
await new Promise((r) => setImmediate(r));
// The shell sets innerHTML; we check the captured HTML for the button label.
// The innerHTML of hostEl reflects the last shell() call.
const html = hostEl.innerHTML || '';
assert.ok(
html.includes('Calibrate'),
'button must read Calibrate when vocalCalibration present (even if noteDetect absent) — input: facade present, noteDetect absent'
);
assert.equal(
html.includes('>Continue<'),
false,
'must NOT read Continue when vocalCalibration is present'
);
});
+5 -6
View File
@@ -33,8 +33,7 @@ class FakeMetaDb:
self.conn.execute( self.conn.execute(
"""CREATE TABLE songs ( """CREATE TABLE songs (
filename TEXT, title TEXT, artist TEXT, filename TEXT, title TEXT, artist TEXT,
genre TEXT DEFAULT '', arrangements TEXT, genre TEXT DEFAULT '', arrangements TEXT
tuning_name TEXT DEFAULT '', tuning_sort_key INTEGER DEFAULT 0
)""" )"""
) )
@@ -47,7 +46,7 @@ class FakeMetaDb:
last_played_at, seconds_total)) last_played_at, seconds_total))
if in_library: if in_library:
self.conn.execute( self.conn.execute(
"INSERT INTO songs SELECT ?, ?, ?, ?, ?, '', 0 WHERE NOT EXISTS " "INSERT INTO songs SELECT ?, ?, ?, ?, ? WHERE NOT EXISTS "
"(SELECT 1 FROM songs WHERE filename = ?)", "(SELECT 1 FROM songs WHERE filename = ?)",
(filename, filename.replace(".feedpak", "").title(), "Test Artist", (filename, filename.replace(".feedpak", "").title(), "Test Artist",
genre, genre,
@@ -55,10 +54,10 @@ class FakeMetaDb:
filename)) filename))
self.conn.commit() self.conn.commit()
def add_song_only(self, filename, genre="", tuning_name=""): def add_song_only(self, filename, genre=""):
"""A library song with no plays — feeds the genre (brochure) list.""" """A library song with no plays — feeds the genre (brochure) list."""
self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?, ?, 0)", self.conn.execute("INSERT INTO songs VALUES (?, ?, ?, ?, ?)",
(filename, filename, "Test Artist", genre, None, tuning_name)) (filename, filename, "Test Artist", genre, None))
self.conn.commit() self.conn.commit()
-130
View File
@@ -1,130 +0,0 @@
"""Tests for career gig tuning preference filtering (feedBack career-gig-tuning)."""
import importlib
import sys
import types
import pytest
# ---------------------------------------------------------------------------
# Helpers to import the career routes module in isolation
# ---------------------------------------------------------------------------
def _load_routes():
"""Import plugins/career/routes.py with minimal stubs for non-fastapi deps."""
import importlib.util, pathlib
path = pathlib.Path(__file__).parent.parent / "plugins" / "career" / "routes.py"
spec = importlib.util.spec_from_file_location("career_routes_test", path)
mod = importlib.util.module_from_spec(spec)
# Stub out lib.* deps only — fastapi IS installed and must not be stubbed
lib_stubs = ["lib.song", "lib.audio", "lib.sloppak"]
for s in lib_stubs:
if s not in sys.modules:
sys.modules[s] = types.ModuleType(s)
spec.loader.exec_module(mod)
return mod
@pytest.fixture(scope="module")
def career():
return _load_routes()
# ---------------------------------------------------------------------------
# _tuning_ok_fn — classification logic
# ---------------------------------------------------------------------------
class TestTuningOkFn:
def test_any_returns_none(self, career):
assert career._tuning_ok_fn("any") is None
def test_empty_returns_none(self, career):
assert career._tuning_ok_fn("") is None
def test_unknown_returns_none(self, career):
assert career._tuning_ok_fn("bogus") is None
def test_standard_matches_e_standard(self, career):
fn = career._tuning_ok_fn("standard")
assert fn("E Standard") is True
def test_standard_matches_eb_standard(self, career):
fn = career._tuning_ok_fn("standard")
assert fn("Eb Standard") is True
def test_standard_rejects_drop_d(self, career):
fn = career._tuning_ok_fn("standard")
assert not fn("Drop D")
def test_standard_rejects_empty(self, career):
fn = career._tuning_ok_fn("standard")
assert not fn("")
def test_drop_matches_drop_d(self, career):
fn = career._tuning_ok_fn("drop")
assert fn("Drop D") is True
def test_drop_matches_double_drop_d(self, career):
fn = career._tuning_ok_fn("drop")
assert fn("Double Drop D") is True
def test_drop_rejects_e_standard(self, career):
fn = career._tuning_ok_fn("drop")
assert not fn("E Standard")
def test_drop_rejects_empty(self, career):
fn = career._tuning_ok_fn("drop")
assert not fn("")
def test_specific_exact_match(self, career):
fn = career._tuning_ok_fn("specific:Open G")
assert fn("Open G") is True
assert not fn("Open A")
def test_specific_empty_value_returns_none(self, career):
# "specific:" with no value is degenerate — treated as any (None)
assert career._tuning_ok_fn("specific:") is None
def test_specific_too_long_returns_none(self, career):
assert career._tuning_ok_fn("specific:" + "x" * 65) is None
# ---------------------------------------------------------------------------
# _fill_genre_songs — tuning filter forwarded correctly
# ---------------------------------------------------------------------------
class TestFillGenreSongs:
"""Smoke-test that _fill_genre_songs respects tuning_ok."""
def _patch_db(self, career, rows):
fake_db = types.SimpleNamespace(
conn=types.SimpleNamespace(execute=lambda q: types.SimpleNamespace(fetchall=lambda: rows))
)
career._state["meta_db"] = fake_db
def test_no_filter_returns_all(self, career):
rows = [
("a.sloppak", "Song A", "Artist", "rock", "E Standard"),
("b.sloppak", "Song B", "Artist", "rock", "Drop D"),
]
self._patch_db(career, rows)
result = career._fill_genre_songs("rock", set(), 10, tuning_ok=None)
assert len(result) == 2
def test_standard_filter_excludes_drop(self, career):
rows = [
("a.sloppak", "Song A", "Artist", "rock", "E Standard"),
("b.sloppak", "Song B", "Artist", "rock", "Drop D"),
]
self._patch_db(career, rows)
fn = career._tuning_ok_fn("standard")
result = career._fill_genre_songs("rock", set(), 10, tuning_ok=fn)
assert len(result) == 1
assert result[0]["filename"] == "a.sloppak"
def test_empty_result_when_no_match(self, career):
rows = [("a.sloppak", "Song A", "Artist", "rock", "Drop D")]
self._patch_db(career, rows)
fn = career._tuning_ok_fn("standard")
result = career._fill_genre_songs("rock", set(), 10, tuning_ok=fn)
assert result == []
+1 -1
View File
@@ -33,7 +33,7 @@ BUNDLED_CONTENT = REPO_ROOT / "data" / "progression"
def test_bundled_content_loads_clean(): def test_bundled_content_loads_clean():
content, warnings = load_content(BUNDLED_CONTENT) content, warnings = load_content(BUNDLED_CONTENT)
assert warnings == [] assert warnings == []
assert set(content["paths"]) == {"guitar", "bass", "drums", "keys", "vocals"} assert set(content["paths"]) == {"guitar", "bass", "drums", "keys"}
assert content["challenge_index"] assert content["challenge_index"]
assert content["quests"]["daily"]["count"] == 3 assert content["quests"]["daily"]["count"] == 3
assert content["quests"]["weekly"]["count"] == 2 assert content["quests"]["weekly"]["count"] == 2
-223
View File
@@ -1,223 +0,0 @@
"""Scan prune guard — background_scan() must refuse to prune when the listing looks
degraded (feedBack#P1-libpurge).
Three cases:
Case A zero listing (original guard):
Failing input: dlc dir completely empty, songs table has 1 row.
Without guard: delete_missing({}) fires all rows deleted.
With guard: scan aborts with stage='error', row survives.
Case B partial listing (Creed r1 HIGH):
Failing input: DB has 2 rows, dlc dir shows only 1 file (neither DB row visible).
Without guard: delete_missing prunes both invisible rows catastrophic loss.
With guard: would_remove(2) >= threshold(1) stage='error', both rows survive.
Case C full rescan bypass:
Same partial-degraded setup, but scan.kick_scan(allow_mass_prune=True).
Guard logs a warning and proceeds; delete_missing runs normally.
"""
import importlib
import sys
import unittest.mock as mock
import pytest
@pytest.fixture()
def prune_guard_env(tmp_path, monkeypatch, reset_scan_state):
"""Isolated scan env with seeding mocked out, in-process executor, pre-populated DB."""
import concurrent.futures
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.delenv("DLC_DIR", raising=False)
# Empty dlc dir — no feedpak/sloppak/wem files anywhere
dlc = tmp_path / "dlc"
dlc.mkdir()
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
# Mock out builtin seeding so the dlc dir stays empty (simulates a RO FUSE
# mount where seed writes fail silently and the listing returns nothing).
monkeypatch.setattr("builtin_content.seed_builtin_diagnostic_sloppaks",
lambda *a, **kw: None)
monkeypatch.setattr("builtin_content.seed_builtin_starter_content",
lambda *a, **kw: None)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
import scan as scan_mod
monkeypatch.setattr(
scan_mod, "_make_scan_executor",
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=1),
)
yield mod, scan_mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
def test_empty_listing_refuses_prune_when_db_nonempty(prune_guard_env):
"""background_scan() with 0 discovered songs + 1 DB row → stage=error, row survives.
Failing input: dlc dir empty (no feedpak/sloppak/wem), songs table has 1 row.
Expected: row count unchanged, scan_status['stage'] == 'error'.
"""
mod, scan_mod = prune_guard_env
import appstate
# Pre-populate the songs table with one row
appstate.meta_db.put(
"song_that_must_survive.feedpak", 12345.0, 1000,
{"title": "Survivor", "artist": "Test", "album": "", "duration": 1.0,
"tuning": "", "arrangements": [], "format": "archive"},
)
count_before = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
assert count_before == 1, f"pre-condition: 1 row in DB, got {count_before}"
# Run scan — dlc dir is empty, seeding mocked → listing finds 0 songs
scan_mod.background_scan()
count_after = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
assert count_after == count_before, (
f"prune guard must refuse delete_missing when listing returns 0 songs; "
f"DB had {count_before} row(s), now has {count_after}"
)
assert scan_mod._scan_status["stage"] == "error", (
f"scan must set stage='error' when the guard fires, "
f"got {scan_mod._scan_status['stage']!r}"
)
assert scan_mod._scan_status["error"] is not None, "error message must be set"
# ── Case B: partial listing (Creed r1 HIGH) ───────────────────────────────────
@pytest.fixture()
def partial_prune_env(tmp_path, monkeypatch, reset_scan_state):
"""DB has 2 rows (neither on disk), dlc dir shows 1 unrelated visible file."""
import concurrent.futures
monkeypatch.setenv("CONFIG_DIR", str(tmp_path))
monkeypatch.delenv("DLC_DIR", raising=False)
dlc = tmp_path / "dlc"
dlc.mkdir()
(tmp_path / "config.json").write_text('{"dlc_dir": "%s"}' % dlc)
# One visible .feedpak file on disk — makes current_files non-empty so the
# old zero-listing guard would not fire, but both DB rows are absent.
import zipfile
visible = dlc / "only-visible.feedpak"
with zipfile.ZipFile(visible, "w") as zf:
zf.writestr("manifest.yaml", "title: Visible\nartist: Test\n")
monkeypatch.setattr("builtin_content.seed_builtin_diagnostic_sloppaks",
lambda *a, **kw: None)
monkeypatch.setattr("builtin_content.seed_builtin_starter_content",
lambda *a, **kw: None)
sys.modules.pop("server", None)
mod = importlib.import_module("server")
import scan as scan_mod
monkeypatch.setattr(
scan_mod, "_make_scan_executor",
lambda: concurrent.futures.ThreadPoolExecutor(max_workers=1),
)
yield mod, scan_mod
conn = getattr(getattr(mod, "meta_db", None), "conn", None)
if conn is not None:
getattr(sys.modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
def test_partial_listing_refuses_prune_when_mass_threshold_exceeded(partial_prune_env):
"""Creed r1 HIGH: 2 DB rows absent from listing, 1 visible file → auto scan refused.
Failing input:
- DB: lost-one.feedpak, lost-two.feedpak (neither on disk)
- dlc dir: only-visible.feedpak (not in DB)
- Auto scan (allow_mass_prune=False)
Expected:
- Both DB rows survive (count unchanged)
- stage='error', error message set
Fails on f6e9727 (old zero-only guard): would_remove=2, current_files non-empty
old guard skips delete_missing prunes both rows.
"""
mod, scan_mod = partial_prune_env
import appstate
_song_meta = {"title": "T", "artist": "A", "album": "", "duration": 1.0,
"tuning": "", "arrangements": [], "format": "archive"}
appstate.meta_db.put("lost-one.feedpak", 11111.0, 500, _song_meta)
appstate.meta_db.put("lost-two.feedpak", 22222.0, 500, _song_meta)
count_before = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
assert count_before == 2, f"pre-condition: 2 rows in DB, got {count_before}"
# Auto scan — allow_mass_prune stays False (default)
scan_mod.background_scan()
count_after = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
assert count_after == count_before, (
f"mass-prune guard must refuse when would_remove={count_before - count_after} "
f"exceeds threshold; DB had {count_before} row(s), now has {count_after}"
)
assert scan_mod._scan_status["stage"] == "error", (
f"scan must set stage='error' when the guard fires, "
f"got {scan_mod._scan_status['stage']!r}"
)
assert scan_mod._scan_status["error"] is not None, "error message must be set"
# ── Case C: full rescan bypasses the guard ────────────────────────────────────
def test_full_rescan_allows_prune_past_threshold(partial_prune_env):
"""Full rescan (allow_mass_prune=True) proceeds even when would_remove >= threshold.
Same partial-degraded setup as Case B, but the user explicitly invoked
/api/rescan/full which sets allow_mass_prune=True. The guard logs a warning
and does not abort; delete_missing runs and prunes the absent rows.
Failing input: same as Case B.
Expected: both absent rows pruned, stage='complete' (or 'scanning').
"""
mod, scan_mod = partial_prune_env
import appstate
_song_meta = {"title": "T", "artist": "A", "album": "", "duration": 1.0,
"tuning": "", "arrangements": [], "format": "archive"}
appstate.meta_db.put("lost-one.feedpak", 11111.0, 500, _song_meta)
appstate.meta_db.put("lost-two.feedpak", 22222.0, 500, _song_meta)
count_before = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
assert count_before == 2
# Full rescan — allow_mass_prune=True (user-authorised)
scan_mod.background_scan(allow_mass_prune=True)
count_after = appstate.meta_db.conn.execute(
"SELECT COUNT(*) FROM songs").fetchone()[0]
# The two absent rows are pruned; only-visible may or may not have been indexed
# (it has a minimal manifest so sloppak detection may skip it — that's fine,
# the key invariant is that the guard did NOT abort).
assert scan_mod._scan_status["stage"] != "error", (
f"full rescan must not abort on mass-prune threshold; "
f"got stage={scan_mod._scan_status['stage']!r}"
)
assert count_after < count_before, (
f"full rescan must have pruned the absent rows; "
f"DB had {count_before} row(s), now has {count_after}"
)