GeoASCII

Transforms GeoGuessr panoramas into a live, fully customizable ASCII text art display with native retro filter controls.

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

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

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

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

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

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         GeoASCII
// @description  Transforms GeoGuessr panoramas into a live, fully customizable ASCII text art display with native retro filter controls.
// @version      1.13.0
// @author       maxtmiller
// @match        https://www.geoguessr.com/*
// @run-at       document-start
// @license      MIT
// @icon         https://raw.githubusercontent.com/maxtmiller/GeoASCII/main/assets/geoascii-icon-32.png
// @namespace    https://github.com/maxtmiller/GeoASCII
// @grant        none
// @tag          geoguessr
// @tag          games
// @tag          ascii
// ==/UserScript==


// Default Baseline Configurations
const DEFAULT_RESOLUTION = 150;
const DEFAULT_CONTRAST = 1.0;
const DEFAULT_SATURATION = 1.0;
const DEFAULT_BRIGHTNESS = 1.0;
const DEFAULT_NOISE = 0;
const DEFAULT_BLUR = 0.0;
const DEFAULT_TINT_HEX = "#bc13fe";
const DEFAULT_PALETTE_SIZE = 10;
const DEFAULT_CMD_COLOURS = false;

let asciiEnabled = true;
let charResolution = DEFAULT_RESOLUTION;
let contrastSetting = DEFAULT_CONTRAST;
let saturationSetting = DEFAULT_SATURATION;
let brightnessSetting = DEFAULT_BRIGHTNESS;
let noiseSetting = DEFAULT_NOISE;
let blurSetting = DEFAULT_BLUR;
let paletteSizeSetting = DEFAULT_PALETTE_SIZE;

let resolutionSettingEnabled = true;
let paletteSettingEnabled = true;
let brightnessSettingEnabled = true;
let contrastSettingEnabled = true;
let saturationSettingEnabled = true;
let noiseSettingEnabled = true;
let blurSettingEnabled = true;

// Feature Toggles
let solidPixelMode = false;
let edgeDetectionMode = false;
let colorInversionMode = false;
let scanlineMode = false;
let thermalMode = false;
let tintMode = false;
let cmdColoursMode = DEFAULT_CMD_COLOURS;
let customTintHex = DEFAULT_TINT_HEX;

// --- Performance System ---
let cachedGameContainer = null;
let cachedWebGlCanvas = null;
let cachedIsGame = false;
let lifecycleCheckInterval = null;
let forceRender = true;
let renderQueued = false;
let cameraMoving = true;
let lastMovementTime = 0;
let userInteracting = false;
let interactionTimeout = null;
let mapInteractionUntil = 0;
let mapPointerDown = false;
let mapPointerId = null;
let activeWebGlCanvas = null;
let waitingForFirstAsciiFrame = true;
let transitionBlockedCanvas = null;
let transitionBlockedFrameSignature = "";
let transitionSameCanvasBlockUntil = 0;
let transitionBlockUntil = 0;

const ACTIVE_FPS = 15;
const MOVEMENT_FPS = 30;
const FIRST_FRAME_FPS = 60;
const IDLE_FPS = 2;
const CHANGE_CHECK_FPS = 20;
const MAP_WHEEL_PAUSE_MS = 180;
const MAP_POINTER_RELEASE_PAUSE_MS = 120;
const TRANSITION_SAME_CANVAS_BLOCK_MS = 900;
const TRANSITION_BLOCK_FALLBACK_MS = 1200;
let currentFpsInterval = 1000 / ACTIVE_FPS;
let lastChangeCheckTime = 0;

const MASTER_PALETTE = '`,_!;|\\\"~^lr[](\\/L)>t<vTz?icf1{sIxYjJno}CZyVwmSXRqM$O9&NW0Q';
const MASTER_PALETTE_BASE = '@%#*+=-:. ';

let cachedPaletteSize = null;
let cachedPalette = "";

function getDynamicPalette() {
    const effectivePaletteSize = paletteSettingEnabled ? paletteSizeSetting : DEFAULT_PALETTE_SIZE;

    if (cachedPaletteSize === effectivePaletteSize) return cachedPalette;

    if (effectivePaletteSize >= MASTER_PALETTE.length) {
        cachedPaletteSize = effectivePaletteSize;
        cachedPalette = MASTER_PALETTE;
        return cachedPalette;
    }
    let result = "";
    const step = (MASTER_PALETTE.length - 1) / (effectivePaletteSize - 1);
    for (let i = 0; i < effectivePaletteSize; i++) {
        result += MASTER_PALETTE_BASE[i];
    }
    if (effectivePaletteSize > 10) {
        for (let i = 0; i < effectivePaletteSize - 10; i++) {
            const index = Math.round(i * step);
            result += MASTER_PALETTE[index];
        }
    }
    cachedPaletteSize = effectivePaletteSize;
    cachedPalette = result;
    return result;
}

let lastKnownWebGLWidth = 0;
let lastKnownWebGLHeight = 0;
let observedPanoramaContainer = null;
let containerMutationObserver = null;
let panelIsOpen = false;

// --- Core WebGL Interception Engine ---
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attributes) {
    if (type === 'webgl' || type === 'webgl2') {
        attributes = attributes || {};
        attributes.preserveDrawingBuffer = true;
        this.dataset.isInterceptedWebgl = "true";
    }
    return originalGetContext.call(this, type, attributes);
};

// Inject Styles
const styleElement = document.createElement("style");
styleElement.innerHTML = `
#ascii-art-canvas {
    position: absolute !important;
    top: 0 !important;
    left: 0 !important;
    width: 100vw !important;
    height: 100vh !important;
    background-color: #05010a !important;
    overflow: hidden !important;
    z-index: 2147483000 !important;
    pointer-events: none !important;
    display: flex !important;
    justify-content: center !important;
    align-items: center !important;
}
#ascii-display-canvas {
    transform-origin: center center !important;
    image-rendering: pixelated !important;
}
#ascii-control-panel {
    position: absolute;
    bottom: max(20px, 10vh);
    left: 20px;
    z-index: 99999;
    background: rgba(14, 5, 24, 0.95);
    border: 1px solid rgba(176, 38, 255, 0.3);
    border-radius: 14px;
    padding: 16px 8px 16px 16px;
    color: #f3e8ff;
    width: min(340px, 60vw);
    max-height: 75vh;
    display: none;
    flex-direction: column;
    box-shadow: 0 12px 40px rgba(0,0,0,0.7);
    backdrop-filter: blur(6px);
    font-family: Neo Sans, sans-serif, Helvetica, Arial;
}
.ascii-panel-content {
    overflow-y: auto;
    overflow-x: hidden;
    flex-grow: 1;
    padding-right: 12px;
}
.ascii-panel-content::-webkit-scrollbar { width: 4px; }
.ascii-panel-content::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.02); }
.ascii-panel-content::-webkit-scrollbar-thumb { background: rgba(176, 38, 255, 0.4); border-radius: 4px; }
[class*="styles_friendChatButton__"] { width: auto !important; height: auto !important; display: inline-flex !important; flex-direction: column !important; align-items: flex-start !important; }
[class*="styles_friendChatButton__"] > :not(.native-ascii-btn) { order: 1 !important; }
.native-ascii-btn {
    order: 2 !important;
    background: rgba(0, 0, 0, 0.6) !important;
    color: #b026ff !important;
    border: 1px solid rgba(176, 38, 255, 0.4) !important;
    border-radius: 24px !important;
    min-width: 110px !important;
    height: 40px !important;
    font-weight: bold !important;
    font-size: 13px !important;
    text-transform: uppercase !important;
    letter-spacing: 0.8px !important;
    cursor: pointer !important;
    backdrop-filter: blur(4px) !important;
    box-shadow: 0 0 15px rgba(176, 38, 255, 0.3), inset 0 0 10px rgba(176, 38, 255, 0.1) !important;
    transition: all 0.2s ease-in-out !important;
    display: flex !important;
    align-items: center !important;
    justify-content: center !important;
    padding: 0 16px !important;
    margin: 4px 0 !important;
}
.native-ascii-btn:hover {
    background: rgba(0, 0, 0, 0.8) !important;
    color: #c55eff !important;
    border-color: rgba(176, 38, 255, 0.8) !important;
    box-shadow: 0 0 25px rgba(176, 38, 255, 0.6), inset 0 0 10px rgba(176, 38, 255, 0.2) !important;
}
.ascii-btn-row { display: flex; gap: 8px; margin-top: 12px; padding-right: 8px; }
.ascii-action-btn { flex: 1; background: rgba(176, 38, 255, 0.2); border: 1px solid rgba(176, 38, 255, 0.5); color: #f3e8ff; padding: 8px 4px; border-radius: 6px; font-weight: bold; font-size: 10px; letter-spacing: 0.5px; text-transform: uppercase; cursor: pointer; transition: all 0.15s ease-in-out; }
.ascii-action-btn:hover { background: rgba(176, 38, 255, 0.4); border-color: #c55eff; box-shadow: 0 0 10px rgba(176, 38, 255, 0.4); }
.ascii-action-btn.reset-variant { background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.2); }
.ascii-action-btn.reset-variant:hover { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.4); box-shadow: 0 0 10px rgba(255, 255, 255, 0.1); }
.panel-title { font-size: 13px; font-weight: bold; text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 12px; color: #c55eff; border-bottom: 1px solid rgba(176, 38, 255, 0.2); padding-bottom: 5px; flex-shrink: 0; padding-right: 8px; }
.ascii-slider-group { margin-bottom: 10px; }
.ascii-slider-group label { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; letter-spacing: 0.5px; gap: 10px; }
.ascii-slider-control { display: flex; align-items: center; gap: 8px; }
.ascii-slider { width: 100%; accent-color: #b026ff; cursor: pointer; }
.ascii-slider-control .ascii-slider { flex: 1; min-width: 0; }
.ascii-toggle-group { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; font-size: 11px; }
.ascii-checkbox { accent-color: #b026ff; cursor: pointer; width: 16px; height: 16px; }
.ascii-color-picker-wrapper { display: flex; align-items: center; gap: 8px; }
.ascii-color-picker { background: none; border: none; width: 28px; height: 24px; cursor: pointer; padding: 0; }
.ascii-details { margin-top: 12px; border-top: 1px solid rgba(176, 38, 255, 0.2); padding-top: 6px; }
.ascii-summary { font-size: 11px; font-weight: bold; text-transform: uppercase; letter-spacing: 1px; color: #c55eff; cursor: pointer; padding: 6px 0; user-select: none; outline: none; display: flex; justify-content: space-between; align-items: center; }
.ascii-summary::-webkit-details-marker { display: none; }
.ascii-summary::after { content: '▼'; font-size: 9px; transition: transform 0.2s ease; color: rgba(176, 38, 255, 0.7); }
.ascii-details[open] .ascii-summary::after { transform: rotate(180deg); }
`;

function appendStyleElement() {
    const styleParent = document.head || document.documentElement;
    if (!styleParent || styleElement.isConnected) return;

    styleParent.appendChild(styleElement);
}

appendStyleElement();
document.addEventListener('DOMContentLoaded', appendStyleElement, { once: true });

let asciiWrapper = null;
let displayCanvas = null;
let displayCtx = null;
let captureCanvas = document.createElement('canvas');
let captureCtx = captureCanvas.getContext('2d', { willReadFrequently: true });
let probeCanvas = document.createElement('canvas');
let probeCtx = probeCanvas.getContext('2d', { willReadFrequently: true });
let previousFrameSignature = "";
const CMD_COLOURS = [
    [0, 0, 0],
    [0, 0, 128],
    [0, 128, 0],
    [0, 128, 128],
    [128, 0, 0],
    [128, 0, 128],
    [128, 128, 0],
    [192, 192, 192],
    [128, 128, 128],
    [0, 0, 255],
    [0, 255, 0],
    [0, 255, 255],
    [255, 0, 0],
    [255, 0, 255],
    [255, 255, 0],
    [255, 255, 255]
];
const cmdColourStringCache = CMD_COLOURS.map(([r, g, b]) => `rgb(${r},${g},${b})`);
const rgbStringCache = new Array(4096);

function hexToRgb(hex) {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
        r: parseInt(result[1], 16),
        g: parseInt(result[2], 16),
        b: parseInt(result[3], 16)
    } : { r: 188, g: 19, b: 254 };
}

function commandPromptColourString(r, g, b) {
    let bestIndex = 0;
    let bestDistance = Infinity;

    for (let i = 0; i < CMD_COLOURS.length; i++) {
        const colour = CMD_COLOURS[i];
        const dr = r - colour[0];
        const dg = g - colour[1];
        const db = b - colour[2];
        const distance = dr * dr + dg * dg + db * db;

        if (distance < bestDistance) {
            bestDistance = distance;
            bestIndex = i;
        }
    }

    return cmdColourStringCache[bestIndex];
}

function quantizedRgbString(r, g, b) {
    const rq = Math.max(0, Math.min(15, r >> 4));
    const gq = Math.max(0, Math.min(15, g >> 4));
    const bq = Math.max(0, Math.min(15, b >> 4));
    const key = (rq << 8) | (gq << 4) | bq;
    let value = rgbStringCache[key];

    if (!value) {
        value = `rgb(${rq * 17},${gq * 17},${bq * 17})`;
        rgbStringCache[key] = value;
    }

    return value;
}

function renderColourString(r, g, b) {
    return cmdColoursMode
        ? commandPromptColourString(r, g, b)
        : quantizedRgbString(r, g, b);
}

function effectiveResolution() {
    return resolutionSettingEnabled ? charResolution : DEFAULT_RESOLUTION;
}

function effectiveBrightness() {
    return brightnessSettingEnabled ? brightnessSetting : DEFAULT_BRIGHTNESS;
}

function effectiveContrast() {
    return contrastSettingEnabled ? contrastSetting : DEFAULT_CONTRAST;
}

function effectiveSaturation() {
    return saturationSettingEnabled ? saturationSetting : DEFAULT_SATURATION;
}

function effectiveNoise() {
    return noiseSettingEnabled ? noiseSetting : DEFAULT_NOISE;
}

function effectiveBlur() {
    return blurSettingEnabled ? blurSetting : DEFAULT_BLUR;
}

function markRendererDirty() {
    forceRender = true;
    previousFrameSignature = "";
}

function closestElement(target, selector) {
    if (!target) return null;

    const element = target.nodeType === Node.ELEMENT_NODE
        ? target
        : target.parentElement;

    return element ? element.closest(selector) : null;
}

function isVisibleElement(element) {
    if (!element) return false;

    const rect = element.getBoundingClientRect();
    const style = window.getComputedStyle(element);

    return (
        rect.width > 20 &&
        rect.height > 20 &&
        rect.bottom > 0 &&
        rect.right > 0 &&
        rect.top < window.innerHeight &&
        rect.left < window.innerWidth &&
        style.display !== 'none' &&
        style.visibility !== 'hidden' &&
        Number(style.opacity || 1) > 0.05
    );
}

function isResultsScreenActive() {
    return !!(
        document.querySelector('[data-qa="result-layout"]') ||
        document.querySelector('[data-qa="round-result"]') ||
        document.querySelector('[data-qa="final-results"]') ||
        document.querySelector('[class*="result-layout"]') ||
        document.querySelector('[class*="game-summary"]')
    );
}

function isGuessMapElement(element) {
    const targetElement = closestElement(element, '*');

    if (
        !targetElement ||
        targetElement.closest('#ascii-control-panel') ||
        targetElement.closest('.native-ascii-btn')
    ) {
        return false;
    }

    const mapElement = targetElement.closest([
        '[data-qa="guess-map"]',
        '[data-qa="guess-map-canvas"]',
        '[data-qa="guess-map__canvas"]',
        '[class*="guess-map"]',
        '[class*="guessMap"]',
        '[class*="mapboxgl-map"]',
        '.mapboxgl-canvas',
        '.leaflet-container'
    ].join(','));

    if (!mapElement || !isVisibleElement(mapElement)) return false;
    if (cachedGameContainer && cachedGameContainer.contains(mapElement)) return false;

    return true;
}

function isRoundTransitionControl(element) {
    const targetElement = closestElement(element, '*');

    if (
        !targetElement ||
        targetElement.closest('#ascii-control-panel') ||
        targetElement.closest('.native-ascii-btn')
    ) {
        return false;
    }

    const control = targetElement.closest('button, a, [role="button"]');
    if (!control || !isVisibleElement(control)) return false;

    const combinedText = [
        control.textContent,
        control.getAttribute('aria-label'),
        control.getAttribute('data-qa'),
        control.className
    ].join(' ').toLowerCase();

    return (
        /\b(next|play)\s+round\b/.test(combinedText) ||
        /\bnext\b/.test(combinedText) && /\bround\b/.test(combinedText) ||
        /\b(play\s+next|start\s+game|play\s+again|new\s+round)\b/.test(combinedText)
    );
}

function handlePossibleRoundTransition(event) {
    if (!cachedIsGame && !isGameRoute()) return;
    if (!isRoundTransitionControl(event.target)) return;

    resetForPanoramaTransition();
    coverPanoramaIfPossible();
    requestImmediateRender();
}

function isTextEntryElement(element) {
    const targetElement = closestElement(element, '*');

    return !!(
        targetElement &&
        (
            targetElement.isContentEditable ||
            targetElement.closest('input, textarea, select, [contenteditable="true"]')
        )
    );
}

function handlePossibleRoundTransitionKey(event) {
    if (event.key !== ' ' && event.key !== 'Enter') return;
    if (!cachedIsGame && !isGameRoute()) return;
    if (isTextEntryElement(event.target)) return;

    const activeElement = document.activeElement;
    const shouldReset =
        isRoundTransitionControl(activeElement) ||
        isRoundTransitionControl(event.target) ||
        isResultsScreenActive();

    if (!shouldReset) return;

    resetForPanoramaTransition();
    coverPanoramaIfPossible();
    requestImmediateRender();
}

function markMapInteraction(event, durationMs) {
    if (isGuessMapElement(event.target)) {
        mapInteractionUntil = performance.now() + durationMs;
    }
}

function isMapInteractionActive() {
    return mapPointerDown || performance.now() < mapInteractionUntil;
}

function beginMapPointer(event) {
    if (!isGuessMapElement(event.target)) return;

    mapPointerDown = true;
    mapPointerId = event.pointerId ?? 'touch';
    mapInteractionUntil = 0;
}

function endMapPointer(event) {
    if (!mapPointerDown) return;
    if (event && mapPointerId !== null && event.pointerId !== undefined && event.pointerId !== mapPointerId) return;

    mapPointerDown = false;
    mapPointerId = null;
    mapInteractionUntil = performance.now() + MAP_POINTER_RELEASE_PAUSE_MS;
}

function handleMapWheel(event) {
    markMapInteraction(event, MAP_WHEEL_PAUSE_MS);
}

function cancelMapInteraction() {
    mapPointerDown = false;
    mapPointerId = null;
    mapInteractionUntil = 0;
}

function beginInteraction(event) {
    if (event && isGuessMapElement(event.target)) {
        return;
    }

    userInteracting = true;
    cameraMoving = true;
    lastMovementTime = performance.now();

    clearTimeout(interactionTimeout);
    interactionTimeout = setTimeout(() => {
        userInteracting = false;
    }, 160);
}

function canvasFrameSignature(webGlCanvas) {
    if (!probeCtx || !webGlCanvas) return "";

    const probeWidth = 24;
    const probeHeight = 14;

    if (probeCanvas.width !== probeWidth || probeCanvas.height !== probeHeight) {
        probeCanvas.width = probeWidth;
        probeCanvas.height = probeHeight;
    }

    try {
        probeCtx.drawImage(webGlCanvas, 0, 0, probeWidth, probeHeight);
        const sample = probeCtx.getImageData(0, 0, probeWidth, probeHeight).data;
        let signature = "";

        for (let i = 0; i < sample.length; i += 32) {
            signature += String.fromCharCode(
                sample[i] >> 3,
                sample[i + 1] >> 3,
                sample[i + 2] >> 3
            );
        }

        return signature;
    } catch (e) {
        return "";
    }
}

function frameSignatureChanged(webGlCanvas) {
    const signature = canvasFrameSignature(webGlCanvas);
    if (!signature) return true;

    const changed = signature !== previousFrameSignature;
    previousFrameSignature = signature;
    return changed;
}

function generateGaussianNoise(stdDev) {
    if (stdDev === 0) return 0;
    let u1 = Math.random();
    let u2 = Math.random();
    if(u1 === 0) u1 = 0.0001;
    let randStdNormal = Math.sqrt(-2.0 * Math.log(u1)) * Math.sin(2.0 * Math.PI * u2);
    return randStdNormal * stdDev;
}

function clearAndForceResizeBuffers() {
    captureCanvas.width = 0;
    captureCanvas.height = 0;
    previousFrameSignature = "";
    forceRender = true;
}

function resetForPanoramaTransition() {
    const currentCanvas = activeWebGlCanvas || cachedWebGlCanvas;
    const now = performance.now();
    transitionBlockedCanvas = currentCanvas;
    transitionBlockedFrameSignature = canvasFrameSignature(currentCanvas);
    transitionSameCanvasBlockUntil = now + TRANSITION_SAME_CANVAS_BLOCK_MS;
    transitionBlockUntil = now + TRANSITION_BLOCK_FALLBACK_MS;
    lastKnownWebGLWidth = 0;
    lastKnownWebGLHeight = 0;
    cachedWebGlCanvas = null;
    activeWebGlCanvas = null;
    clearAndForceResizeBuffers();
    beginFirstFrameMode();
    clearDisplayedAsciiFrame();
}

function beginFirstFrameMode() {
    waitingForFirstAsciiFrame = true;
    forceRender = true;
    lastFrameTime = 0;
    currentFpsInterval = 1000 / FIRST_FRAME_FPS;
}

function clearDisplayedAsciiFrame() {
    if (!displayCanvas || !displayCtx) return;

    displayCtx.setTransform(1, 0, 0, 1, 0, 0);
    displayCtx.fillStyle = '#05010a';
    displayCtx.fillRect(0, 0, displayCanvas.width, displayCanvas.height);
}

function imageDataHasVisibleContent(data) {
    let total = 0;
    const step = Math.max(4, Math.floor(data.length / 256) & ~3);

    for (let i = 0; i < data.length; i += step) {
        total += data[i] + data[i + 1] + data[i + 2];
        if (total > 600) return true;
    }

    return false;
}

function nodeContainsPanoramaCanvas(node) {
    if (!(node instanceof Element)) return false;

    return !!(
        node.matches('canvas, .widget-scene-canvas, [data-is-intercepted-webgl="true"]') ||
        node.querySelector('canvas, .widget-scene-canvas, [data-is-intercepted-webgl="true"]')
    );
}

function mutationTouchesPanoramaCanvas(mutation) {
    return (
        [...mutation.addedNodes].some(nodeContainsPanoramaCanvas) ||
        [...mutation.removedNodes].some(nodeContainsPanoramaCanvas)
    );
}

function randomizeShaders() {
    charResolution = Math.floor(Math.random() * (300 - 80 + 1)) + 80;
    paletteSizeSetting = Math.floor(Math.random() * (MASTER_PALETTE.length - 4 + 1)) + 4;
    brightnessSetting = parseFloat((Math.random() * (1.4 - 0.6) + 0.6).toFixed(2));
    contrastSetting = parseFloat((Math.random() * (2.2 - 0.6) + 0.6).toFixed(1));
    saturationSetting = parseFloat((Math.random() * (2.5 - 0.0) + 0.0).toFixed(1));
    noiseSetting = Math.floor(Math.random() * 5) * 10;
    blurSetting = parseFloat((Math.random() * 1.5).toFixed(1));
    resolutionSettingEnabled = true;
    paletteSettingEnabled = true;
    brightnessSettingEnabled = true;
    contrastSettingEnabled = true;
    saturationSettingEnabled = true;
    noiseSettingEnabled = true;
    blurSettingEnabled = true;

    const modeRoll = Math.random();
    solidPixelMode = modeRoll > 0.80;
    edgeDetectionMode = modeRoll > 0.60 && modeRoll <= 0.80;
    thermalMode = modeRoll > 0.40 && modeRoll <= 0.60;
    colorInversionMode = Math.random() > 0.85;
    scanlineMode = Math.random() > 0.40;
    tintMode = Math.random() > 0.50 || thermalMode === false;
    cmdColoursMode = Math.random() > 0.5;
    customTintHex = "#" + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0');

    syncGlobalVariablesToUi();
    clearAndForceResizeBuffers();
    markRendererDirty();
}

function resetShaders() {
    charResolution = DEFAULT_RESOLUTION;
    paletteSizeSetting = DEFAULT_PALETTE_SIZE;
    contrastSetting = DEFAULT_CONTRAST;
    saturationSetting = DEFAULT_SATURATION;
    brightnessSetting = DEFAULT_BRIGHTNESS;
    noiseSetting = DEFAULT_NOISE;
    blurSetting = DEFAULT_BLUR;
    resolutionSettingEnabled = true;
    paletteSettingEnabled = true;
    brightnessSettingEnabled = true;
    contrastSettingEnabled = true;
    saturationSettingEnabled = true;
    noiseSettingEnabled = true;
    blurSettingEnabled = true;
    solidPixelMode = false;
    edgeDetectionMode = false;
    colorInversionMode = false;
    scanlineMode = false;
    thermalMode = false;
    tintMode = false;
    cmdColoursMode = DEFAULT_CMD_COLOURS;
    customTintHex = DEFAULT_TINT_HEX;

    syncGlobalVariablesToUi();
    clearAndForceResizeBuffers();
    markRendererDirty();
}

function syncGlobalVariablesToUi() {
    if (!document.getElementById('ascii-control-panel')) return;
    document.getElementById('res-enable').checked = resolutionSettingEnabled;
    document.getElementById('res-slider').value = charResolution;
    document.getElementById('res-slider').disabled = !resolutionSettingEnabled;
    document.getElementById('res-val').innerText = effectiveResolution();
    document.getElementById('palette-enable').checked = paletteSettingEnabled;
    document.getElementById('palette-slider').value = paletteSizeSetting;
    document.getElementById('palette-slider').disabled = !paletteSettingEnabled;
    document.getElementById('palette-val').innerText = (paletteSettingEnabled ? paletteSizeSetting : DEFAULT_PALETTE_SIZE) + " Chars";
    document.getElementById('brightness-enable').checked = brightnessSettingEnabled;
    document.getElementById('brightness-slider').value = brightnessSetting;
    document.getElementById('brightness-slider').disabled = !brightnessSettingEnabled;
    document.getElementById('brightness-val').innerText = Math.round(effectiveBrightness() * 100) + "%";
    document.getElementById('contrast-enable').checked = contrastSettingEnabled;
    document.getElementById('contrast-slider').value = contrastSetting;
    document.getElementById('contrast-slider').disabled = !contrastSettingEnabled;
    document.getElementById('contrast-val').innerText = effectiveContrast().toFixed(1);
    document.getElementById('sat-enable').checked = saturationSettingEnabled;
    document.getElementById('sat-slider').value = saturationSetting;
    document.getElementById('sat-slider').disabled = !saturationSettingEnabled;
    document.getElementById('sat-val').innerText = effectiveSaturation().toFixed(1);
    document.getElementById('noise-enable').checked = noiseSettingEnabled;
    document.getElementById('noise-slider').value = noiseSetting;
    document.getElementById('noise-slider').disabled = !noiseSettingEnabled;
    document.getElementById('noise-val').innerText = effectiveNoise();
    document.getElementById('blur-enable').checked = blurSettingEnabled;
    document.getElementById('blur-slider').value = blurSetting;
    document.getElementById('blur-slider').disabled = !blurSettingEnabled;
    document.getElementById('blur-val').innerText = effectiveBlur().toFixed(1) + "px";
    document.getElementById('solid-toggle').checked = solidPixelMode;
    document.getElementById('edge-toggle').checked = edgeDetectionMode;
    document.getElementById('invert-toggle').checked = colorInversionMode;
    document.getElementById('scanline-toggle').checked = scanlineMode;
    document.getElementById('thermal-toggle').checked = thermalMode;
    document.getElementById('cmd-colours-toggle').checked = cmdColoursMode;
    document.getElementById('tint-toggle').checked = tintMode;
    document.getElementById('tint-picker').value = customTintHex;
}

function createUiPanel() {
    let panel = document.getElementById('ascii-control-panel');
    if (!panel) {
        panel = document.createElement('div');
        panel.id = 'ascii-control-panel';
        panel.innerHTML = `
            <div class="panel-title">🎛️ Master Controls</div>
            <div class="ascii-panel-content">
                <div class="ascii-toggle-group" style="margin-bottom: 12px; background: rgba(176,38,255,0.15); padding: 8px; border-radius: 6px;">
                    <span style="font-weight: bold; color: #c55eff;">Enable ASCII Shaders</span>
                    <input type="checkbox" id="master-ascii-toggle" class="ascii-checkbox" ${asciiEnabled ? 'checked' : ''}>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Resolution</span><span id="res-val">${effectiveResolution()}</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="res-enable" class="ascii-checkbox" ${resolutionSettingEnabled ? 'checked' : ''}><input type="range" id="res-slider" class="ascii-slider" min="50" max="350" value="${charResolution}"></div>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Palette Details</span><span id="palette-val">${paletteSettingEnabled ? paletteSizeSetting : DEFAULT_PALETTE_SIZE} Chars</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="palette-enable" class="ascii-checkbox" ${paletteSettingEnabled ? 'checked' : ''}><input type="range" id="palette-slider" class="ascii-slider" min="4" max="${MASTER_PALETTE.length}" step="1" value="${paletteSizeSetting}"></div>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Brightness</span><span id="brightness-val">${Math.round(effectiveBrightness() * 100)}%</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="brightness-enable" class="ascii-checkbox" ${brightnessSettingEnabled ? 'checked' : ''}><input type="range" id="brightness-slider" class="ascii-slider" min="0.5" max="1.5" step="0.05" value="${brightnessSetting}"></div>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Contrast</span><span id="contrast-val">${effectiveContrast().toFixed(1)}</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="contrast-enable" class="ascii-checkbox" ${contrastSettingEnabled ? 'checked' : ''}><input type="range" id="contrast-slider" class="ascii-slider" min="0.5" max="2.5" step="0.1" value="${contrastSetting}"></div>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Saturation</span><span id="sat-val">${effectiveSaturation().toFixed(1)}</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="sat-enable" class="ascii-checkbox" ${saturationSettingEnabled ? 'checked' : ''}><input type="range" id="sat-slider" class="ascii-slider" min="0.0" max="3.0" step="0.1" value="${saturationSetting}"></div>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Gaussian Noise</span><span id="noise-val">${effectiveNoise()}</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="noise-enable" class="ascii-checkbox" ${noiseSettingEnabled ? 'checked' : ''}><input type="range" id="noise-slider" class="ascii-slider" min="0" max="80" step="5" value="${noiseSetting}"></div>
                </div>
                <div class="ascii-slider-group">
                    <label><span>Pre-Blur</span><span id="blur-val">${effectiveBlur().toFixed(1) + "px"}</span></label>
                    <div class="ascii-slider-control"><input type="checkbox" id="blur-enable" class="ascii-checkbox" ${blurSettingEnabled ? 'checked' : ''}><input type="range" id="blur-slider" class="ascii-slider" min="0" max="5" step="0.1" value="${blurSetting}"></div>
                </div>
                <details class="ascii-details">
                    <summary class="ascii-summary">Extra Settings</summary>
                    <div class="ascii-toggle-group"><span>Solid Pixels (█)</span><input type="checkbox" id="solid-toggle" class="ascii-checkbox"></div>
                    <div class="ascii-toggle-group"><span>Edge Detection</span><input type="checkbox" id="edge-toggle" class="ascii-checkbox"></div>
                    <div class="ascii-toggle-group"><span>Invert Colors</span><input type="checkbox" id="invert-toggle" class="ascii-checkbox"></div>
                    <div class="ascii-toggle-group"><span>CRT Scanlines</span><input type="checkbox" id="scanline-toggle" class="ascii-checkbox"></div>
                    <div class="ascii-toggle-group"><span>Thermal / Heatmap</span><input type="checkbox" id="thermal-toggle" class="ascii-checkbox"></div>
                    <div class="ascii-toggle-group"><span>CMD Colours</span><input type="checkbox" id="cmd-colours-toggle" class="ascii-checkbox" ${cmdColoursMode ? 'checked' : ''}></div>
                    <div class="ascii-toggle-group">
                        <span>Color Tint Mode</span>
                        <div class="ascii-color-picker-wrapper">
                            <input type="color" id="tint-picker" class="ascii-color-picker" value="${customTintHex}">
                            <input type="checkbox" id="tint-toggle" class="ascii-checkbox">
                        </div>
                    </div>
                </details>
            </div>
            <div class="ascii-btn-row">
                <button type="button" id="ascii-random-btn" class="ascii-action-btn">🎲 Random</button>
                <button type="button" id="ascii-reset-btn" class="ascii-action-btn reset-variant">🔄 Reset</button>
            </div>
        `;
        document.body.appendChild(panel);
        panel.addEventListener('input', markRendererDirty, { passive: true });
        panel.addEventListener('change', markRendererDirty, { passive: true });

        // Events
        document.getElementById('master-ascii-toggle').addEventListener('change', (e) => { asciiEnabled = e.target.checked; if (asciiEnabled) clearAndForceResizeBuffers(); });
        document.getElementById('res-enable').addEventListener('change', (e) => { resolutionSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); clearAndForceResizeBuffers(); });
        document.getElementById('palette-enable').addEventListener('change', (e) => { paletteSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); clearAndForceResizeBuffers(); });
        document.getElementById('brightness-enable').addEventListener('change', (e) => { brightnessSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); });
        document.getElementById('contrast-enable').addEventListener('change', (e) => { contrastSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); });
        document.getElementById('sat-enable').addEventListener('change', (e) => { saturationSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); });
        document.getElementById('noise-enable').addEventListener('change', (e) => { noiseSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); });
        document.getElementById('blur-enable').addEventListener('change', (e) => { blurSettingEnabled = e.target.checked; syncGlobalVariablesToUi(); });
        document.getElementById('res-slider').addEventListener('input', (e) => { charResolution = parseInt(e.target.value); document.getElementById('res-val').innerText = effectiveResolution(); });
        document.getElementById('palette-slider').addEventListener('input', (e) => { paletteSizeSetting = parseInt(e.target.value); document.getElementById('palette-val').innerText = (paletteSettingEnabled ? paletteSizeSetting : DEFAULT_PALETTE_SIZE) + " Chars"; });
        document.getElementById('brightness-slider').addEventListener('input', (e) => { brightnessSetting = parseFloat(e.target.value); document.getElementById('brightness-val').innerText = Math.round(effectiveBrightness() * 100) + "%"; });
        document.getElementById('contrast-slider').addEventListener('input', (e) => { contrastSetting = parseFloat(e.target.value); document.getElementById('contrast-val').innerText = effectiveContrast().toFixed(1); });
        document.getElementById('sat-slider').addEventListener('input', (e) => { saturationSetting = parseFloat(e.target.value); document.getElementById('sat-val').innerText = effectiveSaturation().toFixed(1); });
        document.getElementById('noise-slider').addEventListener('input', (e) => { noiseSetting = parseInt(e.target.value); document.getElementById('noise-val').innerText = effectiveNoise(); });
        document.getElementById('blur-slider').addEventListener('input', (e) => { blurSetting = parseFloat(e.target.value); document.getElementById('blur-val').innerText = effectiveBlur().toFixed(1) + "px"; });
        document.getElementById('solid-toggle').addEventListener('change', (e) => { solidPixelMode = e.target.checked; });
        document.getElementById('edge-toggle').addEventListener('change', (e) => { edgeDetectionMode = e.target.checked; });
        document.getElementById('invert-toggle').addEventListener('change', (e) => { colorInversionMode = e.target.checked; });
        document.getElementById('scanline-toggle').addEventListener('change', (e) => { scanlineMode = e.target.checked; });
        document.getElementById('thermal-toggle').addEventListener('change', (e) => { thermalMode = e.target.checked; });
        document.getElementById('cmd-colours-toggle').addEventListener('change', (e) => { cmdColoursMode = e.target.checked; });
        document.getElementById('tint-toggle').addEventListener('change', (e) => { tintMode = e.target.checked; });
        document.getElementById('tint-picker').addEventListener('input', (e) => { customTintHex = e.target.value; });
        document.getElementById('ascii-random-btn').addEventListener('click', randomizeShaders);
        document.getElementById('ascii-reset-btn').addEventListener('click', resetShaders);
    }

    const targetChatButtonWrapper = document.querySelector('[class*="styles_friendChatButton__"]');
    if (targetChatButtonWrapper) {
        let customButton = targetChatButtonWrapper.querySelector('.native-ascii-btn');
        if (!customButton) {
            customButton = document.createElement('button');
            customButton.className = 'native-ascii-btn';
            customButton.innerText = panelIsOpen ? '❌ CLOSE' : 'ASCII Shaders';

            customButton.addEventListener('click', (e) => {
                e.preventDefault();
                e.stopPropagation();
                panelIsOpen = !panelIsOpen;
                if (panel) {
                    if (panelIsOpen) {
                        panel.style.display = 'flex';
                        customButton.innerText = '❌ CLOSE';
                    } else {
                        panel.style.display = 'none';
                        customButton.innerText = 'ASCII Shaders';
                    }
                }
            });
            targetChatButtonWrapper.appendChild(customButton);
        }
    }
}

function createAsciiOverlay(gameContainer) {
    const existingWrapper = document.getElementById('ascii-art-canvas');
    if (existingWrapper) {
        asciiWrapper = existingWrapper;
        displayCanvas = existingWrapper.querySelector('#ascii-display-canvas');
        displayCtx = displayCanvas ? displayCanvas.getContext('2d') : null;

        if (existingWrapper.parentElement !== gameContainer) {
            gameContainer.appendChild(existingWrapper);
            clearDisplayedAsciiFrame();
            beginFirstFrameMode();
        }

        return;
    }

    asciiWrapper = document.createElement('div');
    asciiWrapper.id = 'ascii-art-canvas';

    displayCanvas = document.createElement('canvas');
    displayCanvas.id = 'ascii-display-canvas';
    displayCtx = displayCanvas.getContext('2d');
    displayCanvas.width = Math.max(1, window.innerWidth);
    displayCanvas.height = Math.max(1, window.innerHeight);
    clearDisplayedAsciiFrame();

    asciiWrapper.appendChild(displayCanvas);
    gameContainer.appendChild(asciiWrapper);

    // REMOVED createUiPanel() FROM HERE TO PREVENT FRAMERATE OVERRIDES
}

function coverPanoramaIfPossible() {
    if (!asciiEnabled) return;

    const liveGameContainer = document.querySelector('[data-qa=panorama]');
    const gameContainer = liveGameContainer || (cachedGameContainer && cachedGameContainer.isConnected ? cachedGameContainer : null);

    if (liveGameContainer && liveGameContainer !== cachedGameContainer) {
        cachedGameContainer = liveGameContainer;
        resetForPanoramaTransition();
    }

    if (gameContainer) {
        createAsciiOverlay(gameContainer);
        if (waitingForFirstAsciiFrame) beginFirstFrameMode();
    }
}

function processPanoramaToAscii() {
    if (!asciiEnabled) {
        removeAsciiCanvasOnly();
        return;
    }

    const liveGameContainer = document.querySelector('[data-qa=panorama]');
    const gameContainer = liveGameContainer || (cachedGameContainer && cachedGameContainer.isConnected ? cachedGameContainer : null);
    if (!gameContainer) return;

    if (liveGameContainer && liveGameContainer !== cachedGameContainer) {
        cachedGameContainer = liveGameContainer;
        resetForPanoramaTransition();
    }

    createAsciiOverlay(gameContainer);

    let webGlCanvas = (cachedWebGlCanvas && cachedWebGlCanvas.isConnected ? cachedWebGlCanvas : null) ||
                      gameContainer.querySelector('.widget-scene-canvas') ||
                      gameContainer.querySelector('canvas[data-is-intercepted-webgl="true"]');

    if (!webGlCanvas) {
        if (waitingForFirstAsciiFrame) currentFpsInterval = 1000 / FIRST_FRAME_FPS;
        return;
    }

    if (transitionBlockedCanvas && webGlCanvas !== transitionBlockedCanvas) {
        transitionBlockedCanvas = null;
        transitionSameCanvasBlockUntil = 0;
    }

    if (
        transitionBlockedCanvas &&
        webGlCanvas === transitionBlockedCanvas &&
        performance.now() < transitionSameCanvasBlockUntil
    ) {
        forceRender = true;
        currentFpsInterval = 1000 / FIRST_FRAME_FPS;
        return;
    }

    if (transitionBlockedFrameSignature) {
        const currentSignature = canvasFrameSignature(webGlCanvas);

        if (
            currentSignature &&
            currentSignature === transitionBlockedFrameSignature &&
            performance.now() < transitionBlockUntil
        ) {
            forceRender = true;
            currentFpsInterval = 1000 / FIRST_FRAME_FPS;
            return;
        }

        transitionBlockedFrameSignature = "";
        transitionBlockedCanvas = null;
        transitionSameCanvasBlockUntil = 0;
        transitionBlockUntil = 0;
    }

    if (webGlCanvas !== activeWebGlCanvas) {
        activeWebGlCanvas = webGlCanvas;
        beginFirstFrameMode();
        clearDisplayedAsciiFrame();
        clearAndForceResizeBuffers();
    }

    if (webGlCanvas.width <= 300 || webGlCanvas.height <= 150) {
        if (waitingForFirstAsciiFrame) currentFpsInterval = 1000 / FIRST_FRAME_FPS;
        return;
    }

    if (webGlCanvas.width !== lastKnownWebGLWidth || webGlCanvas.height !== lastKnownWebGLHeight) {
        lastKnownWebGLWidth = webGlCanvas.width;
        lastKnownWebGLHeight = webGlCanvas.height;
        clearAndForceResizeBuffers();
        if (waitingForFirstAsciiFrame) {
            clearDisplayedAsciiFrame();
        }
    }

    if (isMapInteractionActive()) {
        currentFpsInterval = 1000 / IDLE_FPS;
        return;
    }

    const now = performance.now();
    const currentNoise = effectiveNoise();
    const needsAnimatedFrame = currentNoise > 0;
    let frameChanged = waitingForFirstAsciiFrame || forceRender || needsAnimatedFrame;

    if (!waitingForFirstAsciiFrame && !frameChanged && now - lastChangeCheckTime >= 1000 / CHANGE_CHECK_FPS) {
        lastChangeCheckTime = now;
        frameChanged = frameSignatureChanged(webGlCanvas);
    }

    if (frameChanged) {
        cameraMoving = true;
        lastMovementTime = now;
    } else if (now - lastMovementTime > 180 && !userInteracting) {
        cameraMoving = false;
    }

    if (waitingForFirstAsciiFrame) {
        currentFpsInterval = 1000 / FIRST_FRAME_FPS;
    } else if (cameraMoving || userInteracting) {
        currentFpsInterval = 1000 / MOVEMENT_FPS;
    } else if (needsAnimatedFrame) {
        currentFpsInterval = 1000 / ACTIVE_FPS;
    } else {
        currentFpsInterval = 1000 / IDLE_FPS;
    }

    if (!frameChanged && !forceRender && !needsAnimatedFrame) {
        return;
    }

    const targetWidth = effectiveResolution();
    const canvasAspect = webGlCanvas.height / webGlCanvas.width;
    const targetHeight = Math.round(targetWidth * canvasAspect * 1.35);

    if (
        !targetWidth ||
        !targetHeight ||
        targetWidth < 2 ||
        targetHeight < 2 ||
        !isFinite(targetWidth) ||
        !isFinite(targetHeight)
    ) {
        return;
    }

    if (captureCanvas.width !== targetWidth || captureCanvas.height !== targetHeight) {
        captureCanvas.width = targetWidth;
        captureCanvas.height = targetHeight;
        displayCanvas.width = targetWidth * 7;
        displayCanvas.height = targetHeight * 10;
    }

    try {
        captureCtx.clearRect(0, 0, targetWidth, targetHeight);
        const currentBlur = effectiveBlur();
        if (currentBlur > 0) { captureCtx.filter = `blur(${currentBlur}px)`; } else { captureCtx.filter = 'none'; }
        captureCtx.drawImage(webGlCanvas, 0, 0, targetWidth, targetHeight);
        captureCtx.filter = 'none';

        const imgData = captureCtx.getImageData(0, 0, targetWidth, targetHeight);
        const data = imgData.data;
        if (!imageDataHasVisibleContent(data)) {
            if (waitingForFirstAsciiFrame) {
                forceRender = true;
                currentFpsInterval = 1000 / FIRST_FRAME_FPS;
            }
            return;
        }

        forceRender = false;

        displayCtx.fillStyle = '#05010a';
        displayCtx.fillRect(0, 0, displayCanvas.width, displayCanvas.height);
        displayCtx.font = "bold 11px 'Courier New', Courier, monospace";
        displayCtx.textBaseline = "top";

        const cellWidth = 7;
        const cellHeight = 10;
        const tintRgb = hexToRgb(customTintHex);
        const currentPalette = getDynamicPalette();
        const currentBrightness = effectiveBrightness();
        const currentContrast = effectiveContrast();
        const currentSaturation = effectiveSaturation();

        for (let y = 0; y < targetHeight; y++) {
            const isScanlineRow = scanlineMode && (y % 2 === 0);
            for (let x = 0; x < targetWidth; x++) {
                const i = (y * targetWidth + x) * 4;
                let r = data[i]; let g = data[i+1]; let b = data[i+2];

                if (currentBrightness !== 1.0) { r *= currentBrightness; g *= currentBrightness; b *= currentBrightness; }
                if (colorInversionMode) { r = 255 - r; g = 255 - g; b = 255 - b; }
                if (currentContrast !== 1.0) {
                    r = ((r - 128) * currentContrast) + 128;
                    g = ((g - 128) * currentContrast) + 128;
                    b = ((b - 128) * currentContrast) + 128;
                }
                if (currentSaturation !== 1.0) {
                    const gray = 0.299 * r + 0.587 * g + 0.114 * b;
                    r = gray + (r - gray) * currentSaturation; g = gray + (g - gray) * currentSaturation; b = gray + (b - gray) * currentSaturation;
                }
                if (currentNoise > 0) { const noise = generateGaussianNoise(currentNoise); r += noise; g += noise; b += noise; }

                r = Math.min(255, Math.max(0, r)); g = Math.min(255, Math.max(0, g)); b = Math.min(255, Math.max(0, b));

                let isEdge = false;
                if (edgeDetectionMode && x > 0 && y > 0 && x < targetWidth - 1 && y < targetHeight - 1) {
                    const idxRight = i + 4; const idxDown = i + (targetWidth * 4);
                    const currentBrightness = 0.299 * r + 0.587 * g + 0.114 * b;
                    const rightBrightness = 0.299 * data[idxRight] + 0.587 * data[idxRight+1] + 0.114 * data[idxRight+2];
                    const downBrightness = 0.299 * data[idxDown] + 0.587 * data[idxDown+1] + 0.114 * data[idxDown+2];
                    if (Math.abs(currentBrightness - rightBrightness) > 30 || Math.abs(currentBrightness - downBrightness) > 30) isEdge = true;
                }

                const finalBrightness = 0.299 * r + 0.587 * g + 0.114 * b;
                let charIndex = Math.floor((finalBrightness / 255) * (currentPalette.length - 1));
                let char = currentPalette[(currentPalette.length - 1) - charIndex];

                if (edgeDetectionMode) { char = isEdge ? '*' : ' '; }
                else if (solidPixelMode) { char = char !== ' ' ? '█' : ' '; }

                if (thermalMode && char !== ' ') {
                    const normalized = finalBrightness / 255;
                    if (normalized < 0.25) { r = 0; g = 0; b = 255 * (normalized / 0.25); }
                    else if (normalized < 0.5) { r = 255 * ((normalized - 0.25) / 0.25); g = 0; b = 255; }
                    else if (normalized < 0.75) { r = 255; g = 255 * ((normalized - 0.5) / 0.25); b = 0; }
                    else { r = 255; g = 255 * ((1.0 - normalized) / 0.25); b = 255 * ((normalized - 0.75) / 0.25); }
                }

                if (tintMode && !thermalMode && char !== ' ') {
                    const scalar = finalBrightness / 255;
                    r = tintRgb.r * scalar; g = tintRgb.g * scalar; b = tintRgb.b * scalar;
                }

                if (char !== ' ') {
                    if (isScanlineRow) { displayCtx.fillStyle = renderColourString(r * 0.25, g * 0.25, b * 0.25); }
                    else { displayCtx.fillStyle = renderColourString(r, g, b); }
                    displayCtx.fillText(char, x * cellWidth, y * cellHeight);
                }
            }
        }
        const scaleX = window.innerWidth / displayCanvas.width;
        const scaleY = window.innerHeight / displayCanvas.height;
        displayCanvas.style.transform = `scale(${scaleX * 1.005}, ${scaleY * 1.005})`;
        waitingForFirstAsciiFrame = false;
    } catch (e) {
        if (waitingForFirstAsciiFrame) {
            forceRender = true;
            currentFpsInterval = 1000 / FIRST_FRAME_FPS;
        }
    }
}

function removeAsciiCanvasOnly() {
    const existingCanvas = document.getElementById('ascii-art-canvas');
    if (existingCanvas) existingCanvas.remove();
}

function removeFullUiOverlay() {
    removeAsciiCanvasOnly();
    const container = document.getElementById('ascii-control-panel');
    if (container) container.remove();
}

function setupPanoramaMutationObserver(targetContainer) {
    if (observedPanoramaContainer === targetContainer) return;
    if (containerMutationObserver) containerMutationObserver.disconnect();

    observedPanoramaContainer = targetContainer;
    containerMutationObserver = new MutationObserver((mutations) => {
        for (let mutation of mutations) {
            if (mutation.type === 'childList') {
                const ownOverlayChanged =
                    [...mutation.addedNodes].some(n => n.id === 'ascii-art-canvas') ||
                    [...mutation.removedNodes].some(n => n.id === 'ascii-art-canvas');

                if (ownOverlayChanged) continue;
                if (!mutationTouchesPanoramaCanvas(mutation)) continue;

                resetForPanoramaTransition();
                requestImmediateRender();
                break;
            }
        }
    });
    containerMutationObserver.observe(targetContainer, { childList: true, subtree: true });
}

let lastFrameTime = 0;

function isGameRoute() {
    return location.pathname.includes('/game/') ||
           location.pathname.includes('/duels/') ||
           location.pathname.includes('/challenge/') ||
           location.pathname.includes('/battle-royale/') ||
           location.pathname.includes('/multiplayer/') ||
           location.pathname.includes('/singleplayer/');
}

function updateCachedElements() {
    cachedIsGame = isGameRoute();

    if (!cachedIsGame) {
        cachedGameContainer = null;
        cachedWebGlCanvas = null;
        activeWebGlCanvas = null;
        waitingForFirstAsciiFrame = true;
        return;
    }

    const liveGameContainer = document.querySelector('[data-qa=panorama]');

    if (liveGameContainer && liveGameContainer !== cachedGameContainer) {
        if (cachedGameContainer) resetForPanoramaTransition();
        cachedGameContainer = liveGameContainer;
    } else {
        cachedGameContainer = liveGameContainer;
    }

    cachedWebGlCanvas = cachedGameContainer
        ? cachedGameContainer.querySelector('.widget-scene-canvas') ||
          cachedGameContainer.querySelector('canvas[data-is-intercepted-webgl="true"]')
        : null;
}

function updateUiVisibility() {
    if (!cachedGameContainer) return;

    setupPanoramaMutationObserver(cachedGameContainer);
    createUiPanel();

    const panel = document.getElementById('ascii-control-panel');
    const existingButton = document.querySelector('.native-ascii-btn');
    const isResultsScreen = isResultsScreenActive();

    const panoramaReady =
        cachedWebGlCanvas &&
        cachedWebGlCanvas.width > 300 &&
        cachedWebGlCanvas.height > 150;

    const shouldShowUi =
        !!cachedGameContainer &&
        !!existingButton &&
        panoramaReady &&
        !isResultsScreen;

    if (existingButton) {
        existingButton.style.display = shouldShowUi ? 'flex' : 'none';
    }

    if (panel) {
        panel.style.display = panelIsOpen && shouldShowUi ? 'flex' : 'none';
    }
}

function startLifecyclePolling() {
    if (lifecycleCheckInterval) clearInterval(lifecycleCheckInterval);

    lifecycleCheckInterval = setInterval(() => {
        updateCachedElements();

        if (!cachedIsGame) {
            if (lastKnownWebGLWidth !== 0 || lastKnownWebGLHeight !== 0) {
                lastKnownWebGLWidth = 0;
                lastKnownWebGLHeight = 0;
                clearAndForceResizeBuffers();
            }

            if (containerMutationObserver) {
                containerMutationObserver.disconnect();
                containerMutationObserver = null;
                observedPanoramaContainer = null;
            }

            removeFullUiOverlay();
            return;
        }

        updateUiVisibility();

        if (cachedGameContainer && cachedWebGlCanvas && !document.getElementById('ascii-art-canvas')) {
            forceRender = true;
            requestImmediateRender();
        }
    }, 250);
}

function requestImmediateRender() {
    forceRender = true;

    if (renderQueued) return;

    renderQueued = true;
    requestAnimationFrame(() => {
        renderQueued = false;
        processPanoramaToAscii();
    });
}

function liveRenderLoop(timestamp) {
    requestAnimationFrame(liveRenderLoop);

    if (document.hidden) return;

    if (!asciiEnabled) {
        removeAsciiCanvasOnly();
        return;
    }

    if (!cachedIsGame || !cachedGameContainer) {
        updateCachedElements();
    }

    if (!cachedIsGame || !cachedGameContainer) return;

    coverPanoramaIfPossible();

    const elapsed = timestamp - lastFrameTime;
    if (elapsed < currentFpsInterval) return;

    lastFrameTime = timestamp - (elapsed % currentFpsInterval);
    processPanoramaToAscii();
}

window.addEventListener('mousemove', beginInteraction, { passive: true });
window.addEventListener('mousedown', beginInteraction, { passive: true });
window.addEventListener('pointermove', beginInteraction, { passive: true });
window.addEventListener('touchmove', beginInteraction, { passive: true });
window.addEventListener('wheel', beginInteraction, { passive: true });
window.addEventListener('wheel', handleMapWheel, { passive: true });
window.addEventListener('pointerdown', beginMapPointer, { passive: true });
window.addEventListener('pointerup', endMapPointer, { passive: true });
window.addEventListener('pointercancel', endMapPointer, { passive: true });
window.addEventListener('blur', cancelMapInteraction, { passive: true });
window.addEventListener('touchstart', beginMapPointer, { passive: true });
window.addEventListener('touchend', endMapPointer, { passive: true });
window.addEventListener('touchcancel', cancelMapInteraction, { passive: true });
window.addEventListener('pointerdown', handlePossibleRoundTransition, { capture: true, passive: true });
window.addEventListener('click', handlePossibleRoundTransition, { capture: true, passive: true });
window.addEventListener('keydown', handlePossibleRoundTransitionKey, { capture: true, passive: true });
document.addEventListener('visibilitychange', () => {
    if (document.hidden) cancelMapInteraction();
}, { passive: true });

startLifecyclePolling();
updateCachedElements();
requestAnimationFrame(liveRenderLoop);