Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion plugins/keys_highway_3d/plugin.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
20 changes: 11 additions & 9 deletions plugins/keys_highway_3d/screen.js
Original file line number Diff line number Diff line change
Expand Up @@ -1498,7 +1498,7 @@
return _bgAudio;
} catch (e) {
if (ctx && typeof ctx.close === 'function') {
try { ctx.close(); } catch (_) {}

Check warning on line 1501 in plugins/keys_highway_3d/screen.js

View workflow job for this annotation

GitHub Actions / ci / lint

File has too many lines (4641). Maximum allowed is 1500
}
console.warn('[Keys-Hwy3D] failed to set up audio analyser:', e);
_bgAudio = { failed: true, permanent: !!(e && e.name === 'InvalidStateError') };
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Retaining _hitNoteKeys across a mastery change turns a replayed already-hit note into a wrong-note miss

judgeHit (screen.js:669) skips any note whose noteKey is already in hitKeys and returns null. With the judged sets no longer cleared by _resetScoring() here, a note that was hit, then dropped out of the playable set by a mastery change, then brought back into view and played again falls through to the else branch in _checkHit (screen.js:3483-3496): matchesHiddenHandNote returns false (hand filter is not excluding it), so it books a _misses++ and a _streak = 0. A correctly replayed note is penalised and breaks the run's streak — the exact class of bug #94 is about.

_onSeek('back') → forgetJudgmentsFrom does clear the stale keys, so loop-wrap and Section Practice rewinds are covered, and a note already in the past cannot be replayed without a seek — so this needs a hit within HIT_TOLERANCE_S of the mastery change. That is narrow, which is why this is a WARNING rather than CRITICAL, but it is a real hole in the retention the new comment relies on.

Cheap hardening: when a mastery change rebuilds the playable set, drop keys for notes that are no longer present (for (const k of _hitNoteKeys) if (!newKeys.has(k)) _hitNoteKeys.delete(k)), or have judgeHit distinguish "already hit" from "no match" so a replay is idempotent rather than a miss.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this against judgeHit and _checkHit; I don't think it's a regression, so I'm leaving the code as is.

  • judgeHit only iterates _notation.playable, so a retained key for a note that is no longer in the playable set is never consulted. A stale key can only matter for a note that is present, within HIT_TOLERANCE_S of the press, and already hit.
  • That case is a second press of an already-hit note inside the tolerance window. main already books it as a miss, with no mastery change involved, because judgeHit skips keys in hitKeys. The doc comment on judgeHit lists "a duplicate" among the null returns.
  • Before this PR, a mastery change in that window cleared hitKeys, so the duplicate press would score a second hit on one chart note. Retaining the keys makes the behavior consistent with the no-mastery-change case, and the totals stay correct.
  • The "dropped then brought back" detail doesn't change this: the note was still hit once, and a second press within the window is still a duplicate.

Pruning keys for absent notes would be harmless but wouldn't change any reachable outcome, so I'm not adding it. If you'd rather make duplicate presses neutral instead of misses, that would be a separate change to _checkHit, since it affects non-mastery play too.


Generated by Claude Code

// 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);
}

Expand Down
11 changes: 11 additions & 0 deletions plugins/keys_highway_3d/tests/instance_invariants.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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\)/);
});
30 changes: 29 additions & 1 deletion tests/test_gp2rs_gpx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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('<MasterBar><Time>4/4</Time><Bars>0</Bars></MasterBar>')
bars = {'0': safe_fromstring('<Bar><Voices>0 1</Voices></Bar>')}
voices = {
'0': safe_fromstring('<Voice><Beats>0</Beats></Voice>'),
'1': safe_fromstring('<Voice><Beats>1</Beats></Voice>'),
}
beats = {
'0': safe_fromstring('<Beat><Rhythm ref="0"/></Beat>'),
'1': safe_fromstring(
'<Beat><Rhythm ref="0"/><Lyrics><Line>hello</Line></Lyrics>'
'<Notes>0</Notes></Beat>'),
}
notes = {'0': safe_fromstring(
'<Note><Properties><Property name="String"><String>0</String></Property>'
'<Property name="Fret"><Fret>5</Fret></Property></Properties></Note>')}
rhythms = {'0': safe_fromstring('<Rhythm><NoteValue>Quarter</NoteValue></Rhythm>')}
out = convert_vocal_track(
safe_fromstring('<GPIF><MasterBars/></GPIF>'),
{'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
Loading