From 075c6884c3c02274f65723bfbc07833b691635ff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 14:13:36 +0000 Subject: [PATCH 1/4] fix(keys_highway_3d): keep run score across mastery changes (#94) A mastery change rebuilt the playable list and called _resetScoring(), wiping hits/misses/streak whenever Difficulty Ladder adjusted mastery. Judged-note keys are (t, midi)-keyed so they stay valid; only the sweep cursor is re-anchored. Also pins #103 (multi-voice lyrics) with a test. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UarYwGSPw2H3i54yUixA5G Signed-off-by: Claude --- CHANGELOG.md | 1695 +++++++++-------- plugins/keys_highway_3d/plugin.json | 2 +- plugins/keys_highway_3d/screen.js | 20 +- .../tests/instance_invariants.test.js | 11 + tests/test_gp2rs_gpx.py | 29 + 5 files changed, 900 insertions(+), 857 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 690f26c9..d41a2ccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,847 +1,848 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- **Keys Highway 3D: per-panel hand filter.** The plugin declares a `handFilter` setting (Both / LH / RH) in `capabilities.visualization.settings`, and each renderer instance implements `applySetting` / `getSetting`. Split Screen can now show a different hand in each panel (feedBack-plugin-splitscreen#66). An instance with no override follows the global Settings value as before. Re-saving the *same* global hand value no longer rebuilds the notes or resets the current score mid-song. -- **Player identity capability for concurrent local play.** The Host now owns stable session/player contexts, profile readiness, redacted diagnostics, lifecycle events, private highway bindings, and player-scoped difficulty routing for Split Screen, karaoke, Note Detection, and Difficulty Ladder integrations. -- **Coordinated deterministic frame rendering for Split Screen visualizations.** Highway instances can now opt into a host-owned frame driver, allowing every panel in a split layout to receive the same `requestAnimationFrame` timestamp and frame ID. Custom visualization renderers receive those values in their render bundle, so frame-dependent effects and pause/throttle decisions no longer vary with the browser's per-panel callback order. Existing single-player and plugin highways retain their self-scheduled rendering loop. -- **Highway now supports deterministic offline export frames.** `renderFrameAt(time)` paints one explicit chart time without sampling the playback clock, allowing Visual Export to encode reproducible frames while playback is paused. -- **keys_highway_3d now supports practicing a single hand** (Both / Left / Right). The 3D keys highway rendered and scored every labelled note in the chart regardless of which hand played it, so a focused left- or right-hand pass wasn't possible on two-handed notation. A new persisted `Hands` control (player settings, `keys3d_hand_filter`) splits rendering *and* scoring along the labels: notes marked `lh`/`rh` are kept or hidden per the selection, while unlabelled and nonstandard-staff (e.g. `solo`) notes stay visible and scoreable in every mode so a hand-agnostic part never silently disappears. Playing a charted note from the hidden hand is treated as neutral rather than a wrong-note miss, and switching the filter mid-run restarts scoring from the current playback position so notes the player already passed are never retroactively counted as missed. - -### Fixed - -- **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`. -- **`safe_xml` now fails closed instead of silently parsing untrusted XML unsafely.** When `defusedxml` was absent, `safe_parse()`/`safe_fromstring()` fell back to stdlib `xml.etree.ElementTree`, silently reopening the XML entity-expansion ("billion laughs") DoS the earlier hardened-parsing fix was meant to close. Both now raise `ET.ParseError` instead. -- **The sha1→sha256 enrichment-hash migration could mask a real metadata change.** The startup re-stamp blindly rewrote every legacy 40-char (SHA-1-length) `content_hash` to the new SHA-256 value, even when the row's stored hash no longer matched the song's current metadata (i.e. the metadata had already changed since the row was last written). Now only rows whose stored digest still matches the freshly computed legacy hash are re-stamped in place; a mismatch is left for the normal identity-change path instead of being silently overwritten. -- **`keys_highway_3d` scoring and rendering fixes** (plugin 0.2.4). Looping or seeking back through a passage no longer scores correctly replayed notes as wrong notes (their earlier hit was never forgotten), and seeking forward no longer counts every skipped note as a miss — a genuine render stall still sweeps elapsed notes as before. A correct hit landing during a wrong-note flash no longer leaves that key glowing red for the rest of the song. Under Split Screen each Keys panel now loads its own arrangement instead of the main player's, and follows arrangement switches. A quick stop/start before three.js finished loading could build two renderers on one canvas and leak settings listeners; that is now guarded. Changing the sharp layout or octave gaps now rebuilds the lanes and notes together live, instead of leaving the notes off their lanes after the next difficulty or hand-filter change. A notation stream that closes early now fails at once instead of after a 20 s timeout, and the stale MIDI audio-input cleanup runs once per page instead of 32 capability commands on every device change. -- **Mastery slider now disables (or partially disables) on phrases whose declared difficulty levels are duplicate content** (#86). A phrase's `max_difficulty`/level count is a claim, not a guarantee, that each tier actually differs — some CDLC-derived arrangements declare multiple `` blocks that all carry byte-identical notes/chords (the source authoring tool never ran per-difficulty simplification), which let the slider move without changing anything rendered. Added `collapse_arrangement_phrases()` (`lib/song.py`), applied at arrangement-load time for both sloppak/feedpak JSON and XML/loose-folder sources: adjacent identical-content levels are merged and renumbered. `hasPhraseData()` (`static/highway.js`) now checks whether any phrase actually has more than one level, rather than merely whether phrase data exists, so the slider disables itself with the existing honest tooltip instead of doing nothing — while phrase `start_time`/`end_time` windows are always preserved (never discarded), since Section Practice's phrase-sized looping depends on that timing independently of whether the difficulty ladder is real. A phrase that genuinely varies is left untouched, even alongside a collapsed neighbor in the same arrangement. -- **`keys_highway_3d` now yields notated vocal arrangements in Auto mode** (#84). -- **`lib/gp2rs.py` now propagates the Guitar Pro arpeggio/handshape flag on import** (#57). GP3/4/5 chord beats never set `arp` on their `ChordTemplate`, so every GP-sourced arpeggiated passage imported as an ordinary strummed chord and the downstream `.feedpak` handshape data (`handshapes[].arp`) was always `false` — even when the source GP file explicitly marked the chord as an arpeggio. The already-implemented downstream consumers (feedpakr's `derive_handshapes()`, the `highway_3d` plugin's arpeggio frame/bracket visualization) were simply never fed the flag for GP imports. Added a `ChordTemplate.arp` field and a `_gp_beat_arpeggio()` detector that reads the same `effect.stroke` "brush" field `_gp_pick_direction()` already uses for strum direction, treating a present, non-`none` stroke as the arpeggio marker (a bare `.pickStroke` stays a plain strum, not an arpeggio). Threaded through to the `` XML attribute, which `lib/song.py`'s existing `_chord_template_arpeggio_flag()` already reads back — no downstream changes needed. Chord templates dedupe by fret voicing **and** arp status together, so a voicing played both arpeggiated in one passage and plainly strummed in another gets two separate templates instead of one shared template "leaking" the arpeggio flag onto the unrelated strum (caught in review on PR #78). -- **keys_highway_3d now respects the mastery/difficulty slider** (#67). The plugin fetches `notation_measures` over its own private `/ws/highway` connection and never consulted the host's difficulty-filtered note stream, so lowering the mastery percentage had no visible effect — the highway always showed the full chart. Notation carries no per-note difficulty tag of its own (it's a structurally separate representation from the tab/gameplay note stream — see feedback-plugin-difficulty-ladder#90), so this correlates each flattened notation event's onset against the chart's phrase windows (now also streamed to this plugin, chunked like `notation_measures`) and the render bundle's ALREADY mastery-filtered tab content (`bundle.notes`/`bundle.chords`): a phrase is "playable" when the tab has any filtered note/chord in that phrase's `[start_time, end_time)` window, and a notation event is kept when its onset falls inside a playable phrase. Recomputes (and rebuilds note meshes + resets scoring) whenever `bundle.mastery` changes; a fixed-difficulty chart with no phrase data is unaffected. - -### Changed - -- **Mastery slider maps through phrase tier numbers.** A phrase's `max_difficulty` now defines its tier scale and each level's `difficulty` the tier where its content starts: the slider picks tier `floor(mastery * (max_difficulty + 1))` and the phrase plays its last level at or below it (`phraseLevelIndexForMastery`, `static/highway.js`). Fully authored ladders (levels `0..n-1`) map exactly as before. `collapse_arrangement_phrases()` (`lib/song.py`) no longer renumbers a partially collapsed ladder to `0..k`; surviving levels keep the tier where their content first appears and the phrase keeps its `max_difficulty`, so a level still covers the slider band it was authored for instead of being stretched across the whole slider (a phrase collapsed to one level still reports `max_difficulty` 0). This lets difficulty_ladder's generated ladders share one song-wide scale, so a slider position means the same difficulty in every phrase. `highway.getPhrases()` additionally reports `top_difficulty`, the tier from which a phrase plays in full. -- **highway_3d: chord diagram widget lingers longer before fading** (`plugins/highway_3d`). The floating chord-shape diagram was visible for only ~0.55s after a chord's onset, reported (against the wrong repo — `feedBack-plugin-fretboard`#2/#3) as "appears and disappears too quickly to read or form the shape." Roughly doubled to 1.1s. See #72 for the cross-repo mismatch this traces back to. - -### Security - -- **The Docker build no longer downloads an unpinned FFmpeg** (#105). The `ffmpeg-fetcher` stage resolved the static BtbN/FFmpeg-Builds tarball through the mutable `latest` release and the `ffmpeg-n7.1-latest-*` asset names, with no checksum: two builds of the same commit could embed different binaries, a change in a moving executable reached the shipped image with executable privileges while bypassing the repository diff and dependency-update review, and the `org.feedBack.ffmpeg.*` labels recorded only names that cannot identify the bytes that shipped. It was also simply broken — BtbN no longer publishes 7.1 assets on `latest`, so the download 404'd and no image could build. The build now pins a month-end `autobuild-*` tag (BtbN's documented retention policy keeps the last build of each month for two years while `latest` floats, so this pin is good until 2028-07-31), verifies each architecture's tarball against a committed SHA-256, and **fails outright** when a pin is missing instead of skipping verification. FFmpeg 7.1 is a terminal (LTS) series with no new upstream fixes, which is why freezing a long-lived image on it is deliberate; the build should be revisited on security grounds, not on a schedule. The release tag, both filenames, and both hashes are recorded in the image labels and in a new `/usr/share/doc/ffmpeg/PROVENANCE.txt` that also points at the GPL corresponding source, and `build-proxmox-ct.sh` — which ships the same binary to the Proxmox CT — is aligned to the same pins. A new hermetic `ci / docker-pins` gate (`tools/check_docker_pins.py`) fails any PR that reverts the release to a floating ref or to a tag naming an impossible date, drops or malforms a checksum, names one asset for both architectures, lets a `-latest-` asset name or an intermediate build stage stand in for a real one, or lets the CT builder drift from the Dockerfile. It reasons about the command the shell actually runs rather than the line's text, so it rejects a verification that is commented out, hidden behind a `RUN` heredoc, pointed at some other file, reading a hardcoded digest instead of the selected pin, fed by a URL that ignores the pinned release, or whose result stops mattering (`|| …`, `;`, a pipe or `&` after it, `!`, `set +e`) — and an empty-pin guard that only warns or reassigns the pin rather than failing the build. It checks the CT builder's constants match the Dockerfile's; the builder's own long-standing `SKIP_HASH_CHECK` escape hatch is left in place as an operator override for a CT that has to build before a pin can be known, and the gate does not police it. -- **Added a per-source-IP connection-attempt rate cap to the `/ws/sync/{session_id}` relay** (feedBack-plugin-splitscreen#26). The relay already rate-caps inbound *messages* per connected socket, but had no cap on how fast one address could open new connection attempts — meaning a scan for a valid LAN-share room key wasn't rate-limited. Added a token-bucket cap (5/sec sustained, burst 32 — kept above `MAX_CLIENTS_PER_ROOM` so a full room's worth of near-simultaneous joins from one shared address isn't rejected by this cap alone) in `lib/routers/ws_sync.py`, checked before session-id validation. This is DoS/scan-cost hygiene, not a real authentication boundary — the room key remains a discovery mechanism, not a secret, consistent with the app's overall trusted-network design (see splitscreen#24). -- **The `/ws/sync/{session_id}` relay now bounds its per-IP rate-limit state** (#106). The connection-attempt cap added for feedBack-plugin-splitscreen#26 kept one permanent dictionary entry per distinct peer address and evicted none, so the only unbounded structure left in a relay that otherwise bounds rooms, clients, frames and message rate grew with every address the server ever saw: stale DHCP and privacy addresses piled up for the lifetime of a long-running NAS process, and a client able to rotate through a large IPv6 prefix could grow the map while sidestepping the per-address cap meant to contain it. A token bucket idle for a full refill window (`CONN_BURST / CONN_RATE_PER_SEC`) holds exactly the burst a bucket that was never created would grant, so those entries carry no state and are swept by a connection attempt from another address — never sooner, so no lockout is shortened. A hard ceiling (`MAX_CONN_BUCKETS`, 256 entries) then evicts the least-recently-*seen* addresses, which is the only bound that covers an address-rotation flood whose entries never idle; a legitimate client that keeps reconnecting moves to the back on every attempt and is never the one dropped. The proxy / client-IP topology the cap actually keys on is now documented in the README's deployment instructions rather than only in the module docstring. -- **Fixed stored XSS in the retune modal.** `retuneSong()` injected - `title`/`target`/`msg.filename`/`msg.error` into a modal's `innerHTML` via - unescaped template literals. `song.title` is attacker-influenceable - (imported GP/MusicXML/sloppak metadata) and reaches this sink directly - from the library card's "Convert to E Standard" menu action. Wrapped all - four values in the existing `esc()` helper, matching the escaping pattern - used everywhere else in the file. -- **Hardened GP/arrangement XML parsing against entity-expansion ("billion - laughs") DoS** (#45). `xml.etree.ElementTree` has no built-in protection - against maliciously nested XML entities on untrusted input; a crafted - imported GP or arrangement XML file could exhaust memory/CPU on the - request thread. Added `lib/safe_xml.py`, a shared hardened-parse helper - using `defusedxml` (now a `requirements.txt` dependency, falling back to - stdlib with a logged warning if somehow absent), and switched every - untrusted-XML parse call site (`lib/gp2rs_gpx.py`, `lib/loosefolder.py`, - `lib/song.py`, `lib/routers/ws_highway.py`) to use it. Rejections are - normalised to `ET.ParseError` so existing `except ET.ParseError:` call - sites keep working unmodified. -- **Capped decompressed size on sloppak zip extraction** (#46). Nothing - bounded a `.sloppak` zip's total decompressed size during extraction — a - highly-compressed malicious/corrupt pack could exhaust disk space (a "zip - bomb"), independent of the unpack-cache LRU eviction (which only bounds - the aggregate cache after the fact). `_unpack_zip()` now sums each - member's declared size against an 8 GB default cap (no decompression - needed to check it), aborting and cleaning up any partial extraction if - exceeded. Override with `FEEDBACK_SLOPPAK_MAX_UNPACK_MB` (`0` disables). -- **Added baseline security headers to every response** (#47): - `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, - `Referrer-Policy: strict-origin-when-cross-origin`, and a - `Content-Security-Policy` restricting `object-src`, `base-uri`, and - `frame-ancestors`, and requiring `'self'` or `https:` for - script/style/media/connect origins. `script-src`/`style-src` still permit - `'unsafe-inline'` — the v3 UI's own HTML uses `onclick="..."` attributes - throughout and inline `