PurrfectScrollSpeed (offline)

Custom scroll speed for singleplayer levels in gpop.io

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 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();

})();