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