PurrfectScrollSpeed (online)

Custom scroll speed for online rooms (private or public lobbies) in gpop.io

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

You will need to install an extension such as Tampermonkey to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         PurrfectScrollSpeed (online)
// @namespace    http://tampermonkey.net/
// @version      2026-02-09
// @description  Custom scroll speed for online rooms (private or public lobbies) in gpop.io
// @author       Purrfect
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gpop.io
// @match        https://gpop.io/room/*
// @match        https://gpop.io/
// @run-at       document-start
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  // --- robust autostart ---
  const MAX_WAIT_MS = 30000;
  const INTERVAL_MS = 250;
  const startedAt = Date.now();

  function ready() {
    return !!(window._$61?.prototype && typeof window._$61.prototype._$5k === "function");
  }

  function start() {
    (() => {
      const SPEED_KEY = "__stickyGameSpeed";
      const UI_POS_KEY = "__purrfect_ui_pos";
      const UI_HIDDEN_KEY = "__purrfect_ui_hidden";
      const UI_OFF_KEY = "__purrfect_ui_off";
      const UI_INIT_KEY = "__purrfect_ui_inited";
      const UI_FLAG = "__purrfect_ui_loaded__";
      const MINI_POS_KEY = "__purrfect_mini_pos";
      const HUE_KEY = "__purrfect_lane_colors";
      const WM = "__purrfect__";

      const DEFAULT_SPEED = 2.5;
      const MAX_SPEED = 10;

      // ========= SAFETY / HOOKS =========
      const SpeedProto = window._$61?.prototype;
      if (!SpeedProto || typeof SpeedProto._$5k !== "function") {
        console.log("PurrfectScrollSpeed: speed prototype not found");
        return;
      }
      if (window[UI_FLAG]) return;
      Object.defineProperty(window, UI_FLAG, { value: true, enumerable: false });

      const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
      const micro = typeof queueMicrotask === "function" ? queueMicrotask : (f) => setTimeout(f, 0);

      const read = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
      const write = (k, v) => { try { localStorage.setItem(k, String(v)); } catch {} };

      const isFirstRun = () => read(UI_INIT_KEY) !== "1";
      const markInited = () => write(UI_INIT_KEY, "1");

      const readHidden = () => read(UI_HIDDEN_KEY) === "1";
      const writeHidden = (v) => write(UI_HIDDEN_KEY, v ? "1" : "0");

      const readOff = () => read(UI_OFF_KEY) === "1";
      const writeOff = (v) => write(UI_OFF_KEY, v ? "1" : "0");

      const readSpeed = () => {
        const v = Number(read(SPEED_KEY));
        return Number.isFinite(v) ? v : DEFAULT_SPEED;
      };
      const writeSpeed = (v) => write(SPEED_KEY, v);

      const savePos = (x, y) => write(UI_POS_KEY, JSON.stringify({ x, y }));
      const loadPos = () => { try { const s = read(UI_POS_KEY); return s ? JSON.parse(s) : null; } catch { return null; } };

      const saveMiniPos = (x, y) => write(MINI_POS_KEY, JSON.stringify({ x, y }));
      const loadMiniPos = () => { try { const s = read(MINI_POS_KEY); return s ? JSON.parse(s) : null; } catch { return null; } };

      try {
        if (!Object.prototype.hasOwnProperty.call(window, WM)) {
          Object.defineProperty(window, WM, { value: "purr", enumerable: false, configurable: false });
        }
      } catch {}

      function findGameInstance() {
        for (const k of Object.keys(window)) {
          try {
            const v = window[k];
            if (v && v instanceof window._$61 && typeof v._$5k === "function") return v;
          } catch {}
        }
        return null;
      }

      // ========= SPEED PATCH =========
      const original5k = SpeedProto._$5k;
      let enabled = !readOff();
      let levelDefault = null;

      function applySpeed(v) {
        const s = clamp(Number(v), 0.5, MAX_SPEED);
        writeSpeed(s);
        const inst = findGameInstance();
        if (inst) { try { inst._$5k(s); } catch {} }
        return s;
      }

      SpeedProto._$5k = function (a) {
        if (!enabled) return original5k.call(this, a);

          // Capture the level's original (vanilla) speed once
          if (levelDefault == null && typeof a === "number" && Number.isFinite(a)) {
              let base = a;
              try { base = _$S._$2q(base); } catch {}
              // keep only sane bounds, but allow up to MAX_SPEED
              base = clamp(base, 0.5, MAX_SPEED);
              levelDefault = base;
          }

          // Compute stable "time-based" hit window from ORIGINAL speed
          // Vanilla: this._$6h = 4, this._$2I = speed * 15
          if (typeof this.__purrfectBaseHitTime !== "number") {
              const baseSpeed = (typeof levelDefault === "number" && Number.isFinite(levelDefault)) ? levelDefault : 1;
              this.__purrfectBaseHitTime = 4 / (baseSpeed * 15);
          }


        let v = Number(read(SPEED_KEY));
        if (!Number.isFinite(v)) v = DEFAULT_SPEED;

        v = clamp(v, 0.5, MAX_SPEED);
        try { v = _$S._$2q(v); } catch {}
        v = clamp(v, 0.5, MAX_SPEED);

        this.gamespeed = v;
        this._$2I = v * 15;
        // Keep judgement window constant in TIME (hitzone scales with scrollspeed)
        this._$6h = this.__purrfectBaseHitTime * this._$2I;
        this._$bU = 100 / this._$2I;
        this._$aX = this._$3H / this._$2I;
        this._$2C = this._$F / this._$2I;

        this._$3m();
        for (let i = 0; i < this._$8c.length; i++) this._$8S(this._$8c[i]);
        this._$5v();
        this._$1D();
        this._$3N();
      };

      // ========= HUE / COLOR PATCH =========
      function normalizeHex(hex) {
        if (typeof hex !== "string") return null;
        let h = hex.trim();
        if (!h) return null;

        // allow: rgb(255,0,0)
        if (/^rgb/i.test(h)) {
          const m = h.match(/(\d+)\D+(\d+)\D+(\d+)/);
          if (!m) return null;
          const r = clamp(parseInt(m[1], 10), 0, 255);
          const g = clamp(parseInt(m[2], 10), 0, 255);
          const b = clamp(parseInt(m[3], 10), 0, 255);
          return ("#" + [r, g, b].map(x => x.toString(16).padStart(2, "0")).join("")).toLowerCase();
        }

        if (h[0] !== "#") h = "#" + h;
        if (/^#[0-9a-f]{3}$/i.test(h)) {
          const r = h[1], g = h[2], b = h[3];
          return ("#" + r + r + g + g + b + b).toLowerCase();
        }
        if (/^#[0-9a-f]{6}$/i.test(h)) return h.toLowerCase();
        return null;
      }

      function hexToRgba(hex, a) {
        const h = normalizeHex(hex);
        if (!h) return null;
        const r = parseInt(h.slice(1, 3), 16);
        const g = parseInt(h.slice(3, 5), 16);
        const b = parseInt(h.slice(5, 7), 16);
        return `rgba(${r}, ${g}, ${b}, ${a})`;
      }

      function loadLaneColors() {
        try {
          const s = read(HUE_KEY);
          if (!s) return { A: null, S: null, D: null, F: null };
          const o = JSON.parse(s);
          return {
            A: normalizeHex(o?.A) || null,
            S: normalizeHex(o?.S) || null,
            D: normalizeHex(o?.D) || null,
            F: normalizeHex(o?.F) || null
          };
        } catch {
          return { A: null, S: null, D: null, F: null };
        }
      }

      function saveLaneColors(obj) {
        const o = {
          A: normalizeHex(obj?.A) || null,
          S: normalizeHex(obj?.S) || null,
          D: normalizeHex(obj?.D) || null,
          F: normalizeHex(obj?.F) || null
        };
        try { write(HUE_KEY, JSON.stringify(o)); } catch {}
      }

      function laneUpper(k) {
        const s = String(k || "").toLowerCase();
        if (s === "a") return "A";
        if (s === "s") return "S";
        if (s === "d") return "D";
        if (s === "f") return "F";
        return null;
      }

      // ---- Falling notes styling (center lanes) ----
      let __purrOrigLaneColors = null;
        function ensureHueStyleTag(mode) {
            let tag = document.getElementById("__purrfect_hue_css");
            if (!tag) {
                tag = document.createElement("style");
                tag.id = "__purrfect_hue_css";
                document.head.appendChild(tag);
            }

            // Room originals from your real in-game probe
            const ROOM_ORIGINALS = {
                A: "rgb(255, 114, 114)",
                S: "rgb(68, 240, 255)",
                D: "rgb(90, 255, 68)",
                F: "rgb(255, 247, 68)",
            };

            // Background tint for OFF
            const ROOM_BG = {
                A: "rgba(255, 114, 114, 0.2)",
                S: "rgba(68, 240, 255, 0.2)",
                D: "rgba(90, 255, 68, 0.2)",
                F: "rgba(255, 247, 68, 0.2)",
            };

            const c = loadLaneColors();

            const ON_VARS = {
                A: c.A || "#ff4b4b",
                S: c.S || "#2fd7ff",
                D: c.D || "#3dff5f",
                F: c.F || "#ffd84a",
            };

            const vars = (mode === "off") ? ROOM_ORIGINALS : ON_VARS;

            tag.textContent = `
:root{
  --purrA:${vars.A};
  --purrS:${vars.S};
  --purrD:${vars.D};
  --purrF:${vars.F};
}

.pp-note.pp-note-a{ color:var(--purrA) !important; }
.pp-note.pp-note-s{ color:var(--purrS) !important; }
.pp-note.pp-note-d{ color:var(--purrD) !important; }
.pp-note.pp-note-f{ color:var(--purrF) !important; }

.pp-note.pp-note-a, .pp-noteextended.pp-note-a{ border-color:var(--purrA) !important; }
.pp-note.pp-note-s, .pp-noteextended.pp-note-s{ border-color:var(--purrS) !important; }
.pp-note.pp-note-d, .pp-noteextended.pp-note-d{ border-color:var(--purrD) !important; }
.pp-note.pp-note-f, .pp-noteextended.pp-note-f{ border-color:var(--purrF) !important; }

${mode === "off" ? `
/* OFF: force true vanilla tints */
.pp-note.pp-note-a, .pp-noteextended.pp-note-a{ background-color:${ROOM_BG.A} !important; box-shadow:none !important; }
.pp-note.pp-note-s, .pp-noteextended.pp-note-s{ background-color:${ROOM_BG.S} !important; box-shadow:none !important; }
.pp-note.pp-note-d, .pp-noteextended.pp-note-d{ background-color:${ROOM_BG.D} !important; box-shadow:none !important; }
.pp-note.pp-note-f, .pp-noteextended.pp-note-f{ background-color:${ROOM_BG.F} !important; box-shadow:none !important; }

.pp-note t, .pp-noteextended t{ color: inherit !important; }
` : ``}
`;
        }


      function styleFallingNote(el) {
        if (!el || !el.notedata) return;
        const lane = laneUpper(el.notedata.key);
        if (!lane) return;

        const colors = loadLaneColors();
        const hex = colors[lane];
        if (!hex) return;

        el.style.borderColor = hex;
        el.style.color = hex;
        el.style.boxShadow = `0 0 0 2px ${hex}`;
        el.style.backgroundColor = hexToRgba(hex, 0.10) || "";

        if (el.classList.contains("pp-noteextended")) {
          el.style.backgroundColor = hexToRgba(hex, 0.16) || "";
          el.style.boxShadow = `0 0 0 2px ${hex}, 0 0 18px ${hexToRgba(hex, 0.20) || hex}`;
        }

        const t = el.querySelector("t");
        if (t) t.style.color = hex;
      }

      function restyleAllFallingNotes() {
          try { document.querySelectorAll(".pp-note, .pp-noteextended").forEach(styleFallingNote); } catch {}
      }


      // Hook creation + recalculation of falling notes
      const noteHuePatch = (() => {
        const Proto = window._$61?.prototype;
        if (!Proto || typeof Proto._$3q !== "function" || typeof Proto._$8S !== "function") return { ok: false };

        const orig3q = Proto._$3q;
        const orig8S = Proto._$8S;

        let installed = false;

        function install() {
          if (installed) return;
          installed = true;

          ensureHueStyleTag("on");

          Proto._$3q = function (...args) {
            const note = orig3q.apply(this, args);
            try { styleFallingNote(note); } catch {}
            return note;
          };

          Proto._$8S = function (note, ...rest) {
            const r = orig8S.call(this, note, ...rest);
            try { styleFallingNote(note); } catch {}
            return r;
          };

          micro(() => restyleAllFallingNotes());
        }

        function uninstall() {
          if (!installed) return;
          installed = false;
          Proto._$3q = orig3q;
          Proto._$8S = orig8S;
          try { ensureHueStyleTag("off"); } catch {}
          micro(() => {
              try {
                  document.querySelectorAll(".pp-note, .pp-noteextended").forEach((el) => {
                      el.style.borderColor = "";
                      el.style.color = "";
                      el.style.boxShadow = "";
                      el.style.backgroundColor = "";
                      const t = el.querySelector("t");
                      if (t) t.style.color = "";
                  });
              } catch {}
          });
        }

        return { ok: true, install, uninstall };
      })();

      // ---- Playerlist notes styling (top-left panels) ----
      const PlpProto = window._$1e?.prototype;

      const huePatch = (() => {
        if (!PlpProto || typeof PlpProto._$aW !== "function" || typeof PlpProto._$2W !== "function") {
          return { ok: false };
        }

        const orig = {
          aW: PlpProto._$aW,
          w2: PlpProto._$2W,
          y7: typeof PlpProto._$7Y === "function" ? PlpProto._$7Y : null,
          r2: typeof PlpProto._$2r === "function" ? PlpProto._$2r : null,
        };

        function laneBaseColor(a, alpha) {
          const lane = laneUpper(a);
          if (!lane) return null;
          const colors = loadLaneColors();
          const hex = colors[lane];
          if (!hex) return null;
          return hexToRgba(hex, alpha);
        }

        function applyToExisting(plp) {
          try {
            for (const k of ["a", "s", "d", "f"]) {
              if (!plp?.notes?.[k]) continue;
              const c = laneBaseColor(k, 0.15);
              if (c) plp.notes[k].style["background-color"] = c;
            }
          } catch {}
        }

        function patchOn() {
          PlpProto._$aW = function (a) {
            const c = laneBaseColor(a, 0.15);
            if (c && this?.notes?.[a]) {
              this.notes[a].style["background-color"] = c;
              return;
            }
            return orig.aW.call(this, a);
          };

          PlpProto._$2W = function (a) {
            const c = laneBaseColor(a, 0.4);
            if (c && this?.notes?.[a]) {
              this.notes[a].style["background-color"] = c;
              this._$ab[a] = 1;
              return;
            }
            return orig.w2.call(this, a);
          };

          if (orig.y7) {
            PlpProto._$7Y = function () {
              var b = _$S.epoch();
              if (this._$br != -1) {
                if (b >= this._$br) {
                  this.score.style.transform = "";
                  this._$br = -1;
                }
              }
              if (this._$bn != -1) {
                if (b >= this._$bn) {
                  this._$4W();
                  this._$bn = -1;
                }
              }
              for (var a in this._$95) {
                if (this._$95[a] != -1) {
                  if (b >= this._$95[a]) {
                    this._$aW(a);
                    this.notes[a + "1"].style.opacity = 0;
                    this._$95[a] = -1;
                    this._$ab[a] = 0;
                  }
                }
              }
            };
          }

          if (orig.r2) {
            PlpProto._$2r = function (a) {
              const lane = laneUpper(a);
              const colors = loadLaneColors();
              const hex = lane ? colors[lane] : null;
              if (hex && this?.notes?.[a]) this.notes[a].style.color = hex;
              return orig.r2.call(this, a);
            };
          }

          micro(() => {
            try {
              const list = window.list;
              if (list?.players) for (const id in list.players) applyToExisting(list.players[id]);
            } catch {}
          });
        }

        function patchOff() {
          PlpProto._$aW = orig.aW;
          PlpProto._$2W = orig.w2;
          if (orig.y7) PlpProto._$7Y = orig.y7;
          if (orig.r2) PlpProto._$2r = orig.r2;

          micro(() => {
            try {
              const list = window.list;
              if (list?.players) {
                for (const id in list.players) {
                  const p = list.players[id];
                  if (!p?.notes) continue;
                  for (const k of ["a", "s", "d", "f"]) {
                    if (p.notes[k]) {
                      p.notes[k].style.color = "";
                      p.notes[k].style["background-color"] = "";
                    }
                  }
                }
              }
            } catch {}
          });
        }

        return { ok: true, patchOn, patchOff };
      })();

      // ========= MOD ON/OFF =========
      function modOn() {
        enabled = true;
        writeOff(false);

        const inst = findGameInstance();
        if (inst && levelDefault == null && typeof inst.gamespeed === "number") levelDefault = inst.gamespeed;

        if (huePatch.ok) huePatch.patchOn();
        if (noteHuePatch.ok) noteHuePatch.install();

        // apply saved speed
        applySpeed(readSpeed());

        micro(() => {
          ensureHueStyleTag("on");
          restyleAllFallingNotes();
          console.log("PurrfectScrollSpeed ON");
        });
      }

      function modOff() {
        enabled = false;
        writeOff(true);

        const inst = findGameInstance();
        if (inst && levelDefault == null && typeof inst.gamespeed === "number") levelDefault = inst.gamespeed;
        if (inst) {
            const back = (typeof levelDefault === "number" && Number.isFinite(levelDefault))
            ? levelDefault
            : (typeof inst.gamespeed === "number" && Number.isFinite(inst.gamespeed) ? inst.gamespeed : DEFAULT_SPEED);

            try { delete inst.__purrfectBaseHitTime; } catch {}
            try { inst._$6h = 4; } catch {} // vanilla hit window base
            try { original5k.call(inst, back); } catch {}

        }


        if (huePatch.ok) huePatch.patchOff();
        if (noteHuePatch.ok) noteHuePatch.uninstall();

        micro(() => console.log("PurrfectScrollSpeed OFF"));
      }

      // quick commands
      window.setGameSpeed = (v) => (enabled ? applySpeed(v) : "Mod is OFF");
      window.purrModOn = () => (modOn(), "ON");
      window.purrModOff = () => (modOff(), "OFF");

      // ========= FIRST RUN DEFAULTS =========
      if (isFirstRun()) {
        writeOff(false);
        writeHidden(false);
        writeSpeed(DEFAULT_SPEED);
        if (!read(HUE_KEY)) saveLaneColors({ A: null, S: null, D: null, F: null });
        enabled = true;
        markInited();
      }

      if (readOff()) modOff();
      else modOn();

      // ========= UI =========
      const root = document.createElement("div");
      root.style.cssText =
        "position:fixed;right:18px;bottom:18px;z-index:2147483647;" +
        "font:12px/1.25 system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif;" +
        "user-select:none;touch-action:none;";

      const panel = document.createElement("div");
      panel.style.cssText =
        "width:260px;border:1px solid rgba(255,255,255,.14);" +
        "background:rgba(20,20,24,.92);backdrop-filter:blur(8px);" +
        "-webkit-backdrop-filter:blur(8px);border-radius:12px;" +
        "box-shadow:0 8px 28px rgba(0,0,0,.45);color:rgba(255,255,255,.92);" +
        "overflow:hidden;";

      const header = document.createElement("div");
      header.style.cssText =
        "display:flex;align-items:center;justify-content:space-between;" +
        "padding:10px 10px 8px;cursor:grab;background:rgba(255,255,255,.04);";

      const title = document.createElement("div");
      title.textContent = "PurrfectScrollSpeed";
      title.style.cssText = "font-weight:700;letter-spacing:.2px;";

      const btnWrap = document.createElement("div");
      btnWrap.style.cssText = "display:flex;gap:6px;align-items:center;";

      const mkBtn = (t) => {
        const b = document.createElement("button");
        b.type = "button";
        b.textContent = t;
        b.style.cssText =
          "all:unset;cursor:pointer;padding:4px 8px;border-radius:8px;" +
          "background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.10);" +
          "color:rgba(255,255,255,.92);";
        b.onmouseenter = () => (b.style.background = "rgba(255,255,255,.12)");
        b.onmouseleave = () => (b.style.background = "rgba(255,255,255,.08)");
        return b;
      };

      const hueBtn = mkBtn("Hue");
      const toggleBtn = mkBtn("Off");
      const hideBtn = mkBtn("Hide");

      btnWrap.append(hueBtn, toggleBtn, hideBtn);
      header.append(title, btnWrap);

      const body = document.createElement("div");
      body.style.cssText = "padding:10px;display:flex;flex-direction:column;gap:10px;";

      const speedRow = document.createElement("div");
      speedRow.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;";

      const speedLabel = document.createElement("div");
      speedLabel.textContent = "Speed";
      speedLabel.style.cssText = "opacity:.85;font-weight:600;";

      const speedValue = document.createElement("div");
      speedValue.style.cssText = "font-variant-numeric:tabular-nums;font-weight:700;";

      speedRow.append(speedLabel, speedValue);

      const slider = document.createElement("input");
      slider.type = "range";
      slider.min = "0.5";
      slider.max = String(MAX_SPEED);
      slider.step = "0.1";
      slider.style.cssText = "width:100%;";

      const presets = document.createElement("div");
      presets.style.cssText = "display:grid;grid-template-columns:1fr 1fr;gap:8px;";

      const addPreset = (name, val) => {
        const b = mkBtn(name);
        b.style.cssText += ";text-align:center;padding:8px 8px;";
        b.onclick = () => {
          if (!enabled) return;
          slider.value = String(val);
          speedValue.textContent = Number(val).toFixed(1);
          applySpeed(val);
        };
        presets.appendChild(b);
      };

      addPreset("Slow 1", 1);
      addPreset("Normal 2.5", 2.5);
      addPreset("Fast 4", 4);
      addPreset("Cracked 6", 6);

      const footer = document.createElement("div");
      footer.style.cssText = "display:flex;gap:8px;align-items:center;justify-content:space-between;";

      const hint = document.createElement("div");
      hint.style.cssText = "opacity:.75;font-size:11px;";
      hint.textContent = "Toggle UI: F9 / Ctrl+Alt+H";

      const purr = document.createElement("div");
      purr.textContent = "Purr";
      purr.style.cssText = "opacity:.12;font-size:10px;letter-spacing:.8px;transform:translateY(1px);";

      footer.append(hint, purr);

      body.append(speedRow, slider, presets, footer);
      panel.append(header, body);
      root.append(panel);

      const mini = document.createElement("button");
      mini.type = "button";
      mini.textContent = "◀";
      mini.style.cssText =
        "all:unset;position:fixed;right:18px;bottom:0px;z-index:2147483647;" +
        "cursor:pointer;padding:8px 10px;border-radius:999px;" +
        "background:rgba(20,20,24,.92);border:1px solid rgba(255,255,255,.14);" +
        "box-shadow:0 8px 28px rgba(0,0,0,.45);color:rgba(255,255,255,.92);" +
        "display:none;user-select:none;touch-action:none;";

      document.documentElement.appendChild(root);
      document.documentElement.appendChild(mini);

      function setVisibility(mode) {
        if (mode === "OFF") {
          panel.style.display = "none";
          mini.style.display = "none";
          writeHidden(true);
          return;
        }
        if (mode === "HIDE") {
          panel.style.display = "none";
          mini.style.display = "block";
          writeHidden(true);
          return;
        }
        panel.style.display = "block";
        mini.style.display = "none";
        writeHidden(false);
      }

      function refreshUI() {
        toggleBtn.textContent = enabled ? "Off" : "On";
        slider.disabled = !enabled;

        const s = clamp(readSpeed(), 0.5, MAX_SPEED);
        slider.value = String(s);
        speedValue.textContent = s.toFixed(1);

        const offStyle = !enabled;
        for (const b of presets.querySelectorAll("button")) {
          b.style.opacity = offStyle ? ".45" : "1";
          b.style.pointerEvents = offStyle ? "none" : "auto";
        }
        slider.style.opacity = offStyle ? ".45" : "1";
        hueBtn.style.opacity = offStyle ? ".45" : "1";
        hueBtn.style.pointerEvents = offStyle ? "none" : "auto";
      }

      slider.addEventListener("input", () => {
        const s = clamp(Number(slider.value), 0.5, MAX_SPEED);
        speedValue.textContent = s.toFixed(1);
        if (enabled) applySpeed(s);
      });

      hideBtn.onclick = () => {
        setVisibility("HIDE");
      };

      toggleBtn.onclick = () => {
          if (enabled) {
              modOff();
              setVisibility("HIDE");
          } else {
              modOn();
              setVisibility("SHOW");
          }
          refreshUI();
      };


      function toggleUI() {
        if (readOff() || !enabled) {
          setVisibility("SHOW");
          refreshUI();
          return;
        }
        const visible = panel.style.display !== "none";
        setVisibility(visible ? "HIDE" : "SHOW");
        refreshUI();
      }

      window.addEventListener("keydown", (e) => {
        if (e.key === "F9") { e.preventDefault(); toggleUI(); return; }
        if (e.ctrlKey && e.altKey && e.key.toLowerCase() === "h") { e.preventDefault(); toggleUI(); return; }
      });

      // drag panel
      let dragging = false;
      let dx = 0, dy = 0;

      header.addEventListener("mousedown", (e) => {
        if (!enabled) return;
        dragging = true;
        header.style.cursor = "grabbing";
        dx = e.clientX - root.getBoundingClientRect().left;
        dy = e.clientY - root.getBoundingClientRect().top;
        e.preventDefault();
      });

      window.addEventListener("mousemove", (e) => {
        if (!dragging) return;
        const nx = clamp(e.clientX - dx, 0, window.innerWidth - root.offsetWidth);
        const ny = clamp(e.clientY - dy, 0, window.innerHeight - root.offsetHeight);

        root.style.left = nx + "px";
        root.style.top = ny + "px";
        root.style.right = "auto";
        root.style.bottom = "auto";

        savePos(nx, ny);
      });

      window.addEventListener("mouseup", () => {
        dragging = false;
        header.style.cursor = "grab";
      });

      let miniDragging = false;
      let mdx = 0, mdy = 0;
      let mx0 = 0, my0 = 0;
      let miniMoved = false;

      mini.addEventListener("mousedown", (e) => {
        if (!readHidden()) return;
        miniDragging = true;
        miniMoved = false;

        const r = mini.getBoundingClientRect();
        mdx = e.clientX - r.left;
        mdy = e.clientY - r.top;
        mx0 = r.left;
        my0 = r.top;

        e.preventDefault();
      });

      window.addEventListener("mousemove", (e) => {
        if (!miniDragging) return;

        const nx = clamp(e.clientX - mdx, 0, window.innerWidth - mini.offsetWidth);
        const ny = clamp(e.clientY - mdy, 0, window.innerHeight - mini.offsetHeight);

        const dist = Math.hypot(nx - mx0, ny - my0);
        if (dist > 5) miniMoved = true;

        mini.style.left = nx + "px";
        mini.style.top = ny + "px";
        mini.style.right = "auto";
        mini.style.bottom = "auto";

        saveMiniPos(nx, ny);
      });

      window.addEventListener("mouseup", () => {
        if (!miniDragging) return;
        const wasMoved = miniMoved;
        miniDragging = false;
        miniMoved = false;

        // only open on a click
        if (!wasMoved) {
          setVisibility("SHOW");
          refreshUI();
        }
      });

      // restore positions
      const pos = loadPos();
      if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) {
        root.style.left = clamp(pos.x, 0, window.innerWidth - 260) + "px";
        root.style.top = clamp(pos.y, 0, window.innerHeight - 140) + "px";
        root.style.right = "auto";
        root.style.bottom = "auto";
      }

      const mpos = loadMiniPos();
      if (mpos && Number.isFinite(mpos.x) && Number.isFinite(mpos.y)) {
        mini.style.left = clamp(mpos.x, 0, window.innerWidth - 36) + "px";
        mini.style.top = clamp(mpos.y, 0, window.innerHeight - 36) + "px";
        mini.style.right = "auto";
        mini.style.bottom = "auto";
      } else {
        const rr = root.getBoundingClientRect();
        const mx = clamp(rr.left + rr.width - 22, 0, window.innerWidth - 36);
        const my = clamp(window.innerHeight - 36, 0, window.innerHeight - 36);
        mini.style.left = mx + "px";
        mini.style.top = my + "px";
        mini.style.right = "auto";
        mini.style.bottom = "auto";
        saveMiniPos(mx, my);
      }

      // ========= Hue modal =========
      function makeHueModal() {
        const overlay = document.createElement("div");
        overlay.style.cssText =
          "position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.35);" +
          "display:flex;align-items:flex-end;justify-content:flex-end;padding:18px;";

        const box = document.createElement("div");
        box.style.cssText =
          "width:320px;background:rgba(20,20,24,.95);border:1px solid rgba(255,255,255,.14);" +
          "border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:rgba(255,255,255,.92);" +
          "padding:12px;display:flex;flex-direction:column;gap:10px;";

        const head = document.createElement("div");
        head.style.cssText = "display:flex;align-items:center;justify-content:space-between;gap:10px;";

        const h = document.createElement("div");
        h.textContent = "Lane Colors (A S D F)";
        h.style.cssText = "font-weight:700;letter-spacing:.2px;";

        const close = mkBtn("Close");
        head.append(h, close);

        const grid = document.createElement("div");
        grid.style.cssText = "display:grid;grid-template-columns:54px 1fr 110px;gap:8px;align-items:center;";

        const colors = loadLaneColors();
        const inputs = {};

        function addRow(lane) {
          const l = document.createElement("div");
          l.textContent = lane;
          l.style.cssText = "opacity:.9;font-weight:700;";

          const text = document.createElement("input");
          text.type = "text";
          text.placeholder = "#rrggbb or rgb(…)";
          text.value = colors[lane] || "";
          text.style.cssText =
            "width:100%;box-sizing:border-box;padding:7px 8px;border-radius:8px;" +
            "border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.06);" +
            "color:rgba(255,255,255,.92);outline:none;";

          const pick = document.createElement("input");
          pick.type = "color";
          pick.value = colors[lane] || "#ffffff";
          pick.style.cssText = "width:100%;height:34px;border:none;background:transparent;padding:0;";

          text.addEventListener("input", () => {
            const hx = normalizeHex(text.value);
            if (hx) pick.value = hx;
          });

          pick.addEventListener("input", () => {
            text.value = pick.value;
          });

          inputs[lane] = { text, pick };
          grid.append(l, text, pick);
        }

        addRow("A");
        addRow("S");
        addRow("D");
        addRow("F");

        const actions = document.createElement("div");
        actions.style.cssText = "display:flex;gap:8px;justify-content:flex-end;margin-top:2px;";

        const clear = mkBtn("Clear");
        const save = mkBtn("Save");

        clear.onclick = () => {
          saveLaneColors({ A: null, S: null, D: null, F: null });

          if (enabled) {
            ensureHueStyleTag("on");
            restyleAllFallingNotes();
            if (huePatch.ok) huePatch.patchOn();
          }
          overlay.remove();
        };

        save.onclick = () => {
          const o = {};
          for (const lane of ["A", "S", "D", "F"]) o[lane] = normalizeHex(inputs[lane].text.value);
          saveLaneColors(o);

          if (enabled) {
            ensureHueStyleTag("on");
            restyleAllFallingNotes();
            if (huePatch.ok) huePatch.patchOn();
          }
          overlay.remove();
        };

        close.onclick = () => overlay.remove();
        actions.append(clear, save);

        box.append(head, grid, actions);
        overlay.append(box);

        overlay.addEventListener("mousedown", (e) => { if (e.target === overlay) overlay.remove(); });
        return overlay;
      }

      hueBtn.onclick = () => {
        if (!enabled) return;
        document.documentElement.appendChild(makeHueModal());
      };

      refreshUI();
      if (readOff() || !enabled) setVisibility("OFF");
      else if (readHidden()) setVisibility("HIDE");
      else setVisibility("SHOW");

      micro(() => console.log("PurrfectScrollSpeed ready"));
    })();
  }

  function tick() {
    if (ready()) {
      start();
      return;
    }

    if (Date.now() - startedAt > MAX_WAIT_MS) {
      console.warn("PurrfectScrollSpeed: timed out waiting for game code.");
      return;
    }

    setTimeout(tick, INTERVAL_MS);
  }

  tick();

})();