diff --git a/CHANGELOG.md b/CHANGELOG.md
index 690f26c9..92cb00db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **Keys Highway 3D: a mastery change no longer resets the run's score.** Hits, misses, streak and best streak now carry over when the mastery slider (or Difficulty Ladder's auto-adjust) rebuilds the playable notes, so the stats posted at song end cover the whole run (#94).
- **Docker and Proxmox builds now include the starter feedpaks.** First-run seeding can find all listed sources instead of repeatedly warning that the packs are missing.
- **`gp8_audio_sync` and `gp_autosync` now route GPIF parsing through the shared hardened parser (`lib/safe_xml.py`) instead of each carrying its own local `defusedxml`-or-stdlib fallback.** Both files parse `Content/score.gpif` out of an imported `.gp`/`.gpx` container — attacker-influenceable input — and previously duplicated the exact try/except-ImportError-then-stdlib-`ET.fromstring` pattern `safe_xml.py` centralized for every other untrusted-XML call site. Consolidating removes two divergent copies of security-relevant parsing logic; also corrected a stale `requirements.txt` comment that claimed both modules already delegated to `safe_xml.py`.
diff --git a/plugins/keys_highway_3d/plugin.json b/plugins/keys_highway_3d/plugin.json
index 4ac3748f..be45ca83 100644
--- a/plugins/keys_highway_3d/plugin.json
+++ b/plugins/keys_highway_3d/plugin.json
@@ -1,7 +1,7 @@
{
"id": "keys_highway_3d",
"name": "Keys Highway 3D",
- "version": "0.2.5",
+ "version": "0.2.6",
"description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.",
"type": "visualization",
"bundled": true,
diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js
index 26c2f5f8..51afa206 100644
--- a/plugins/keys_highway_3d/screen.js
+++ b/plugins/keys_highway_3d/screen.js
@@ -3974,15 +3974,17 @@
_notation.notes, _notation.phrases, tabNotes, tabChords);
_notation.playable = filterNotationByHand(_notation.masteryPlayable, _handFilter);
buildNoteMeshes();
- // The old playable set's indices/entries no longer line up with
- // the new one — a stale _sweepCursor position or a "hit" keyed
- // to a note that just got filtered out would misbehave. This
- // mirrors the same reset loadNotationForCurrentSong already
- // does for a fresh chart load.
- _resetScoring();
- // …but unlike a fresh chart, playback kept advancing — resume
- // the miss sweep from the current position so notes the player
- // already passed aren't retroactively counted as misses.
+ // Unlike a fresh chart load, a mastery change is the SAME run:
+ // hits / misses / streak / best streak carry over, so Difficulty
+ // Ladder adjusting mastery during play doesn't wipe the score
+ // (and the stats posted at song end cover the whole run). The
+ // judged-note sets are keyed by noteKey(t, midi), not by list
+ // index, so they stay valid across the new playable set — and
+ // keeping them stops a note already judged from being counted
+ // again if a later mastery change brings it back into view.
+ // Only the sweep cursor indexes the old list, so re-anchor it:
+ // playback kept advancing, and notes the player already passed
+ // must not be retroactively counted as misses.
_anchorMissSweep(now);
}
diff --git a/plugins/keys_highway_3d/tests/instance_invariants.test.js b/plugins/keys_highway_3d/tests/instance_invariants.test.js
index 49756054..bbb8ea2c 100644
--- a/plugins/keys_highway_3d/tests/instance_invariants.test.js
+++ b/plugins/keys_highway_3d/tests/instance_invariants.test.js
@@ -63,3 +63,14 @@ test('a live sharp-layout / octave-gap change rebuilds lanes and notes together'
assert.match(src, /_sharpMode = d\.sharpMode;\s*_rebuildChartGeometry\(\);/);
assert.match(src, /if \('octaveGaps' in d\.fx\) _rebuildChartGeometry\(\);/);
});
+
+test('a mastery change keeps the run totals instead of resetting scoring', () => {
+ // Difficulty Ladder drives mastery during play; wiping hits/misses/streak
+ // on every adjustment made the run's posted stats cover only the time
+ // since the last change. Judged-note keys are (t, midi)-keyed, so they
+ // survive the playable rebuild; only the sweep cursor needs re-anchoring.
+ const fn = src.match(/function _maybeApplyMasteryFilter\(bundle, now\)\s*\{[\s\S]*?\n {8}\}\n/);
+ assert.ok(fn, '_maybeApplyMasteryFilter not found');
+ assert.doesNotMatch(fn[0], /_resetScoring\(\)/);
+ assert.match(fn[0], /_anchorMissSweep\(now\)/);
+});
diff --git a/tests/test_gp2rs_gpx.py b/tests/test_gp2rs_gpx.py
index d4de4b73..fb131b9a 100644
--- a/tests/test_gp2rs_gpx.py
+++ b/tests/test_gp2rs_gpx.py
@@ -13,7 +13,8 @@
import pytest
import gp2rs_gpx
-from gp2rs_gpx import convert_file
+from gp2rs_gpx import convert_file, convert_vocal_track
+from safe_xml import safe_fromstring
from gp2rs_gpx import (
_decompress_bcfz,
@@ -1350,3 +1351,30 @@ def test_auto_select_gpx_fretted_track_named_keys_is_still_classified_as_keys():
assert tracks[0]['string_pitches'] # sanity: real fret data is present
_indices, names = gp2rs_gpx._auto_select_gpx(tracks)
assert list(names.values()) == ["Keys"]
+
+
+def test_convert_vocal_track_keeps_lyrics_when_first_voice_is_silent():
+ """A silent first voice must not hide the lyric-bearing second voice in
+ the same bar (feedBack#103)."""
+ mb = safe_fromstring('0')
+ bars = {'0': safe_fromstring('0 1')}
+ voices = {
+ '0': safe_fromstring('0'),
+ '1': safe_fromstring('1'),
+ }
+ beats = {
+ '0': safe_fromstring(''),
+ '1': safe_fromstring(
+ 'hello'
+ '0'),
+ }
+ notes = {'0': safe_fromstring(
+ '0'
+ '5')}
+ rhythms = {'0': safe_fromstring('Quarter')}
+ out = convert_vocal_track(
+ safe_fromstring(''),
+ {'string_pitches': [64, 59, 55, 50, 45, 40]},
+ 0, [mb], bars, voices, beats, notes, rhythms)
+ assert 'count="1"' in out
+ assert 'lyric="hello"' in out