PurrfectScrollSpeed (offline)

Custom scroll speed for singleplayer levels 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 यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         PurrfectScrollSpeed (offline)
// @namespace    http://tampermonkey.net/
// @version      2026-02-09
// @description  Custom scroll speed for singleplayer levels in gpop.io
// @author       Purrfect
// @icon         https://www.google.com/s2/favicons?sz=64&domain=gpop.io
// @match        https://gpop.io/play/*
// @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._$3q?.prototype && typeof window._$3q.prototype._$1i === "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._$3q?.prototype;
        if (!SpeedProto || typeof SpeedProto._$1i !== "function") {
            console.log("PurrfectScrollSpeed: scroll 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._$3q && typeof v._$1i === "function") return v;
          } catch {}
        }
        return null;
      }

      // ========= SPEED PATCH =========
      const original1i = SpeedProto._$1i;
      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._$1i(s); } catch {} }
        return s;
      }

        SpeedProto._$1i = function (a) {
            // If mod is OFF, run original behavior
            if (!enabled) return original1i.call(this, a);

            // Capture the level's original speed once
            if (levelDefault == null && typeof a === "number" && Number.isFinite(a)) {
                let base = Math.max(0.5, Math.min(3, a));
                try { base = _$7B._$G(base); } catch {}
                levelDefault = base;
            }

            // Desired speed from sticky setting
            let v = Number(read(SPEED_KEY));
            if (!Number.isFinite(v)) v = DEFAULT_SPEED;

            v = clamp(v, 0.5, MAX_SPEED);
            try { v = _$7B._$G(v); } catch {}
            v = clamp(v, 0.5, MAX_SPEED);

            // Compute a stable "time-based" hit window from the level's ORIGINAL speed
            // Vanilla uses: this._$7n = 4, this._$6D = (speed * 15)
            if (typeof this.__purrfectBaseHitTime !== "number") {
                const baseSpeed = (typeof levelDefault === "number" && Number.isFinite(levelDefault)) ? levelDefault : 1;
                this.__purrfectBaseHitTime = 4 / (baseSpeed * 15);
            }

            // Apply visual speed
            this.gamespeed = v;
            this._$6D = v * 15;

            // Keep judgement window constant in TIME
            this._$7n = this.__purrfectBaseHitTime * this._$6D;

            // Vanilla recompute
            this._$86 = 100 / this._$6D;
            this._$8f = this._$7t / this._$6D;
            this._$5w = this._$V / this._$6D;

            this._$40();
            for (let i = 0; i < this._$2n.length; i++) this._$2A(this._$2n[i]);
            this._$ad();
            this._$b0();
            this._$2X();
        };



      // ========= 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) ----
        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 vanilla
            const OFF_RGB = {
                A: "rgb(255, 114, 114)",
                S: "rgb(68, 240, 255)",
                D: "rgb(90, 255, 68)",
                F: "rgb(255, 247, 68)"
            };
            const OFF_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 isOff = (mode === "off") || readOff();

            const colors = loadLaneColors();
            const onA = colors.A || "#b4c8ff";
            const onS = colors.S || "#44f0ff";
            const onD = colors.D || "#5aff44";
            const onF = colors.F || "#fff744";

            const A = isOff ? OFF_RGB.A : onA;
            const S = isOff ? OFF_RGB.S : onS;
            const D = isOff ? OFF_RGB.D : onD;
            const F = isOff ? OFF_RGB.F : onF;

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

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

${isOff ? `
.pp-note.pp-note-a, .pp-noteextended.pp-note-a{ background-color:${OFF_BG.A} !important; box-shadow:none !important; }
.pp-note.pp-note-s, .pp-noteextended.pp-note-s{ background-color:${OFF_BG.S} !important; box-shadow:none !important; }
.pp-note.pp-note-d, .pp-noteextended.pp-note-d{ background-color:${OFF_BG.D} !important; box-shadow:none !important; }
.pp-note.pp-note-f, .pp-noteextended.pp-note-f{ background-color:${OFF_BG.F} !important; box-shadow:none !important; }
.pp-note t, .pp-noteextended t{ color: inherit !important; }

.pp-note, .pp-noteextended{
  box-shadow:none !important;
  outline:none !important;
  filter:none !important;
}
` : ``}
`;
        }


      function styleFallingNote(el) {
        if (readOff() || !enabled) return;
        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;
      }
        // --- Apply colors to newly spawned notes ---
        let __purrNoteObserver = null;

        function startNoteObserver() {
            if (__purrNoteObserver) return;

            __purrNoteObserver = new MutationObserver((mutations) => {
                for (const m of mutations) {
                    for (const n of m.addedNodes) {
                        if (!n || n.nodeType !== 1) continue;

                        // If the added node IS a note
                        if (n.classList && (n.classList.contains("pp-note") || n.classList.contains("pp-noteextended"))) {
                            styleFallingNote(n);
                            continue;
                        }

                        // Or contains notes
                        if (n.querySelectorAll) {
                            const notes = n.querySelectorAll(".pp-note, .pp-noteextended");
                            if (notes && notes.length) {
                                notes.forEach(styleFallingNote);
                            }
                        }
                    }
                }
            });

            __purrNoteObserver.observe(document.documentElement, { childList: true, subtree: true });
        }

      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 = "";
                el.style.outline = "";
                el.style.filter = "";
                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();
          startNoteObserver();
          console.log("PurrfectScrollSpeed ON");
        });
      }

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

          micro(() => {
              try { ensureHueStyleTag("off"); } catch {}
          });

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

              // Reset hit window base (vanilla) + remove cached base
              try { delete inst.__purrfectBaseHitTime; } catch {}
              try { inst._$7n = 4; } catch {} // vanilla hit window base

              // Reset speed to the level's original
              try { original1i.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 = () => {
        if (readOff() || !enabled) setVisibility("HIDE");
        else 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";
      });

      // drag mini + click to open (no long-hold open)
      let miniDragging = false;
      let mdx = 0, mdy = 0;
      let mx0 = 0, my0 = 0;
      let miniMoved = false;
      let miniPressed = false;

      mini.addEventListener("mousedown", (e) => {
          // Allow click-to-open even when OFF.
          // Only block dragging when OFF.
          const off = readOff() || !enabled;

          miniPressed = true;

          miniDragging = !off;
          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 (!miniPressed) return;
        const wasMoved = miniMoved;
        miniPressed = false;
        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();
            startNoteObserver();
            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();
            startNoteObserver();
            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("HIDE");
      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();

})();