Gartic Phone DrawBot

Automated drawing bot with WebSocket interception, image loading, color quantization, preview and progress indicator

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Gartic Phone DrawBot
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  Automated drawing bot with WebSocket interception, image loading, color quantization, preview and progress indicator
// @author       NotLun1x
// @match        https://garticphone.com/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';
    // --- Event & Error Logging Helpers ---
    function logToPanel(msg, isError = false) {
        try {
            if (typeof document !== 'undefined') {
                const container = document.getElementById('db-log-container');
                const content = document.getElementById('db-log-content');
                if (container && content) {
                    container.style.display = 'block';
                    const color = isError ? '#ef4444' : '#cbd5e1';
                    content.innerHTML += `<div style="color: ${color};">${msg}</div>`;
                    container.scrollTop = container.scrollHeight;
                }
            }
        } catch (e) {
            console.warn('[DrawBot] Log output error:', e);
        }
    }
    if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
        window.addEventListener('error', (e) => {
            logToPanel(`Error: ${e.message} (${e.filename}:${e.lineno})`, true);
        });
        window.addEventListener('unhandledrejection', (e) => {
            logToPanel(`Promise Rejection: ${e.reason}`, true);
        });

        const originalConsoleError = console.error;
        console.error = function (...args) {
            originalConsoleError.apply(console, args);
            logToPanel('[Console.error] ' + args.join(' '), true);
        };
    }

    const SETTINGS_KEY = 'drawbot_settings_v1';
    function loadInitialCFG() {
        const defaults = {
            downscale: 4,
            denoise: false,
            packetDelay: 127,
            fillPPS: 8,
            fillBg: false,
            useBridge: true,
            maxBridgeLength: 50,
            colorsMode: '8',
            currentTurnId: 0
        };
        try {
            const data = localStorage.getItem(SETTINGS_KEY);
            if (data) {
                const saved = JSON.parse(data);
                defaults.downscale = parseInt(saved.scale, 10) || 4;
                defaults.packetDelay = parseInt(saved.delay, 10) || 127;
                defaults.fillPPS = parseInt(saved.fillPPS, 10) || 8;
                defaults.fillBg = saved.fillBg !== undefined ? saved.fillBg : false;
                defaults.useBridge = saved.useBridge !== undefined ? saved.useBridge : true;
                defaults.maxBridgeLength = parseInt(saved.bridgeLen, 10) || 50;
                defaults.colorsMode = saved.colorsMode || '8';
            }
        } catch (e) { }
        return defaults;
    }
    const CFG = loadInitialCFG();
    const FAST_FILL_MAX_FRAME_CHARS = 500000;
    const HEX_COLOR_RE = /^#[0-9A-F]{6}$/i;

    let wsInstance = null;
    let wsPrefix = '42[2,7,';
    let isDrawing = false;
    let cancelFlag = false;
    let isPaused = false;
    let currentImageSrc = '';
    let strokeIdCounter = 1;

    // --- Layout and Custom Scale State ---
    let layoutMode = 'stretch'; // 'stretch', 'center', 'custom'
    let customX = 0;   // 0 to 768
    let customY = 0;   // 0 to 448
    let customW = 384; // default width
    let customH = 224; // default height
    let draftImg = null;
    let isDraggingImage = false;
    let dragStartX = 0;
    let dragStartY = 0;
    let imgStartX = 0;
    let imgStartY = 0;
    let imgStartW = 0;
    let imgStartH = 0;
    let activeDragAction = null; // 'move', 'resize-TL', 'resize-TR', 'resize-BL', 'resize-BR'
    let isApplied = false;

    const GARTIC_PALETTE = [
        { r: 0, g: 0, b: 0 },         // Black
        { r: 102, g: 102, b: 102 },   // Dark Grey
        { r: 0, g: 80, b: 205 },      // Dark Blue
        { r: 255, g: 255, b: 255 },   // White
        { r: 170, g: 170, b: 170 },   // Light Grey
        { r: 38, g: 201, b: 255 },    // Light Blue
        { r: 1, g: 116, b: 32 },      // Dark Green
        { r: 153, g: 0, b: 0 },       // Dark Red
        { r: 150, g: 65, b: 18 },     // Brown
        { r: 17, g: 176, b: 60 },     // Light Green
        { r: 255, g: 0, b: 19 },      // Red
        { r: 255, g: 120, b: 41 },    // Orange
        { r: 176, g: 112, b: 28 },    // Dark Yellow
        { r: 153, g: 0, b: 78 },      // Dark Pink
        { r: 203, g: 90, b: 87 },     // Muted Red
        { r: 255, g: 193, b: 38 },    // Yellow
        { r: 255, g: 0, b: 143 },     // Pink
        { r: 254, g: 175, b: 168 }    // Light Pink
    ];

    // --- WebSocket Interceptor ---
    function handleIncomingSocketData(data) {
        if (typeof data !== 'string') return;
        try {
            const jsonStart = data.indexOf('{');
            if (jsonStart !== -1) {
                const jsonStr = data.substring(jsonStart, data.lastIndexOf('}') + 1);
                const packet = JSON.parse(jsonStr);
                if (packet) {
                    if (typeof packet.turnNum === 'number') {
                        CFG.currentTurnId = packet.turnNum;
                        console.log('[DrawBot] Captured turnNum (incoming):', CFG.currentTurnId);
                    }
                    if (packet.data && typeof packet.data.turnNum === 'number') {
                        CFG.currentTurnId = packet.data.turnNum;
                        console.log('[DrawBot] Captured turnNum (incoming data):', CFG.currentTurnId);
                    }
                }
            }
        } catch (e) { }
    }

    const originalSend = WebSocket.prototype.send;
    WebSocket.prototype.send = function (data) {
        if (typeof data === 'string' && data.startsWith('42[')) {
            if (wsInstance !== this) {
                wsInstance = this;
                console.log('[DrawBot] WebSocket linked successfully!');
                updateStatus('Socket ready. Select a file and click Start.', '#10b981');

                // Intercept incoming messages for turnNum auto-detection
                const self = this;
                try {
                    const originalOnMessage = self.onmessage;
                    self.onmessage = function (event) {
                        handleIncomingSocketData(event.data);
                        if (originalOnMessage) {
                            return originalOnMessage.apply(this, arguments);
                        }
                    };
                    self.addEventListener('message', function (event) {
                        handleIncomingSocketData(event.data);
                    });
                } catch (err) {
                    console.error('[DrawBot] Error listening to incoming messages:', err);
                }
            }

            // Reset stroke counter on state transitions (non-drawing events)
            try {
                const eventMatch = data.match(/^42\[\d+,(\d+)[,\]]/);
                if (eventMatch) {
                    const eventId = parseInt(eventMatch[1], 10);
                    if (eventId !== 7) {
                        if (strokeIdCounter !== 1) {
                            console.log('[DrawBot] State event detected (event ' + eventId + '). Resetting strokeIdCounter to 1.');
                            strokeIdCounter = 1;
                        }
                    }
                }
            } catch (e) { }

            // Dynamically capture the socket message prefix
            const prefixMatch = data.match(/^(42\[\d+,\d+,)\{"t":/);
            if (prefixMatch) {
                wsPrefix = prefixMatch[1];
                console.log('[DrawBot] Captured socket prefix:', wsPrefix);
            } else {
                const genericMatch = data.match(/^42\[(\d+),/);
                if (genericMatch) {
                    const channelId = genericMatch[1];
                    const newPrefix = `42[${channelId},7,`;
                    if (wsPrefix !== newPrefix) {
                        wsPrefix = newPrefix;
                        console.log('[DrawBot] Dynamically updated socket prefix to:', wsPrefix);
                    }
                }
            }

            // Capture the last stroke ID and turn ID sent by the user
            try {
                const jsonStart = data.indexOf('{');
                if (jsonStart !== -1) {
                    const jsonStr = data.substring(jsonStart, data.lastIndexOf('}') + 1);
                    const packet = JSON.parse(jsonStr);
                    if (packet) {
                        if (typeof packet.t === 'number') {
                            CFG.currentTurnId = packet.t;
                            console.log('[DrawBot] Captured turnId (outgoing):', CFG.currentTurnId);
                        }
                        if (packet.v && Array.isArray(packet.v)) {
                            const strokeId = packet.v[1];
                            if (typeof strokeId === 'number') {
                                strokeIdCounter = strokeId + 1; // Direct sync instead of Math.max to allow resetting downwards
                                console.log('[DrawBot] Captured strokeId. Next:', strokeIdCounter);
                            }
                        }
                    }
                }
            } catch (e) {
                // Ignore parsing errors
            }
        }
        return originalSend.apply(this, arguments);
    };

    const originalWebSocket = window.WebSocket;
    window.WebSocket = function (...args) {
        const ws = new originalWebSocket(...args);
        wsInstance = ws;
        return ws;
    };
    window.WebSocket.prototype = originalWebSocket.prototype;

    // --- Helpers ---
    let timerWorker = null;
    const pendingSleeps = new Map();
    let sleepId = 0;

    try {
        const workerCode = `
            self.onmessage = function(e) {
                if (e.data.action === 'start') {
                    setTimeout(() => {
                        self.postMessage({ id: e.data.id });
                    }, e.data.ms);
                }
            };
        `;
        const blob = new Blob([workerCode], { type: 'application/javascript' });
        timerWorker = new Worker(URL.createObjectURL(blob));
        timerWorker.onmessage = function (e) {
            const resolve = pendingSleeps.get(e.data.id);
            if (resolve) {
                pendingSleeps.delete(e.data.id);
                resolve();
            }
        };
    } catch (err) {
        console.error('[DrawBot] Failed to create Web Worker for timers:', err);
    }

    const sleep = ms => new Promise(resolve => {
        if (timerWorker) {
            const id = sleepId++;
            pendingSleeps.set(id, resolve);
            timerWorker.postMessage({ action: 'start', id, ms });
        } else {
            setTimeout(resolve, ms);
        }
    });

    function clampCustomBounds() {
        customW = Math.max(10, Math.min(768, customW));
        customH = Math.max(10, Math.min(448, customH));
        customX = Math.max(0, Math.min(768 - customW, customX));
        customY = Math.max(0, Math.min(448 - customH, customY));
    }

    function updateSliders() {
        const wSlider = document.getElementById('db-custom-w');
        const hSlider = document.getElementById('db-custom-h');
        const xSlider = document.getElementById('db-custom-x');
        const ySlider = document.getElementById('db-custom-y');
        
        if (wSlider) {
            wSlider.value = customW;
            document.getElementById('db-val-w').textContent = Math.round(customW);
        }
        if (hSlider) {
            hSlider.value = customH;
            document.getElementById('db-val-h').textContent = Math.round(customH);
        }
        if (xSlider) {
            xSlider.max = 768 - customW;
            xSlider.value = customX;
            document.getElementById('db-val-x').textContent = Math.round(customX);
        }
        if (ySlider) {
            ySlider.max = 448 - customH;
            ySlider.value = customY;
            document.getElementById('db-val-y').textContent = Math.round(customY);
        }
    }

    function drawDraftPreview() {
        const pCanvas = document.getElementById('db-preview-canvas');
        if (!pCanvas || !currentImageSrc) return;
        const pContainer = document.getElementById('db-preview-container');
        if (pContainer) {
            pContainer.style.display = 'flex';
        }
        const step = parseInt(document.getElementById('db-scale').value, 10);
        const w = Math.round(768 / step);
        const h = Math.round(448 / step);
        pCanvas.width = w;
        pCanvas.height = h;
        const ctx = pCanvas.getContext('2d');
        ctx.clearRect(0, 0, w, h);
        
        // Draw faint outline of the Gartic Phone drawing boundaries
        ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
        ctx.lineWidth = 1;
        ctx.strokeRect(0, 0, w, h);
        
        if (draftImg && draftImg.complete) {
            const cx = customX / step;
            const cy = customY / step;
            const cw = customW / step;
            const ch = customH / step;
            ctx.drawImage(draftImg, cx, cy, cw, ch);
            
            // Bounding box border
            ctx.strokeStyle = '#8b5cf6';
            ctx.lineWidth = 1.5;
            ctx.strokeRect(cx, cy, cw, ch);
            
            // Corner handles (constant visual size of 12px on screen, so half-size is 6px on screen)
            ctx.fillStyle = '#8b5cf6';
            const rect = pCanvas.getBoundingClientRect();
            const hs = (6 * w) / rect.width;
            
            ctx.fillRect(cx - hs, cy - hs, hs * 2, hs * 2); // TL
            ctx.fillRect(cx + cw - hs, cy - hs, hs * 2, hs * 2); // TR
            ctx.fillRect(cx - hs, cy + ch - hs, hs * 2, hs * 2); // BL
            ctx.fillRect(cx + cw - hs, cy + ch - hs, hs * 2, hs * 2); // BR
        }
        
        const infoEl = document.getElementById('db-preview-info');
        if (infoEl) {
            infoEl.innerHTML = `<span style="color: #fbbf24; font-weight: bold;">[Draft Mode] Drag corners to resize or body to move.<br>Click "Apply & Render Preview" to verify drawing.</span>`;
        }
    }

    function handleCanvasMouseDown(e) {
        if (layoutMode !== 'custom' || !draftImg) return;
        const pCanvas = document.getElementById('db-preview-canvas');
        if (!pCanvas) return;
        const rect = pCanvas.getBoundingClientRect();
        
        const mouseX = e.clientX - rect.left;
        const mouseY = e.clientY - rect.top;
        
        const workX = (mouseX / rect.width) * 768;
        const workY = (mouseY / rect.height) * 448;
        
        // Grab threshold of 24 screen pixels (extremely easy to grab corners):
        const handleThreshold = 24 * (768 / rect.width);
        
        const distTL = Math.hypot(workX - customX, workY - customY);
        const distTR = Math.hypot(workX - (customX + customW), workY - customY);
        const distBL = Math.hypot(workX - customX, workY - (customY + customH));
        const distBR = Math.hypot(workX - (customX + customW), workY - (customY + customH));
        
        dragStartX = e.clientX;
        dragStartY = e.clientY;
        imgStartX = customX;
        imgStartY = customY;
        imgStartW = customW;
        imgStartH = customH;
        
        if (distTL < handleThreshold) {
            activeDragAction = 'resize-TL';
        } else if (distTR < handleThreshold) {
            activeDragAction = 'resize-TR';
        } else if (distBL < handleThreshold) {
            activeDragAction = 'resize-BL';
        } else if (distBR < handleThreshold) {
            activeDragAction = 'resize-BR';
        } else if (workX >= customX && workX <= customX + customW &&
                   workY >= customY && workY <= customY + customH) {
            activeDragAction = 'move';
        } else {
            activeDragAction = null;
            return;
        }
        
        isDraggingImage = true;
        document.addEventListener('mousemove', handleCanvasMouseMove);
        document.addEventListener('mouseup', handleCanvasMouseUp);
    }

    function handleCanvasMouseMove(e) {
        if (!isDraggingImage || !activeDragAction) return;
        const pCanvas = document.getElementById('db-preview-canvas');
        if (!pCanvas) return;
        const rect = pCanvas.getBoundingClientRect();
        
        const deltaPageX = e.clientX - dragStartX;
        const deltaPageY = e.clientY - dragStartY;
        
        const deltaX = (deltaPageX / rect.width) * 768;
        const deltaY = (deltaPageY / rect.height) * 448;
        
        if (activeDragAction === 'move') {
            customX = Math.max(0, Math.min(768 - customW, imgStartX + deltaX));
            customY = Math.max(0, Math.min(448 - customH, imgStartY + deltaY));
        } else if (activeDragAction === 'resize-BR') {
            customW = Math.max(10, Math.min(768 - customX, imgStartW + deltaX));
            customH = Math.max(10, Math.min(448 - customY, imgStartH + deltaY));
        } else if (activeDragAction === 'resize-BL') {
            customX = Math.max(0, Math.min(imgStartX + imgStartW - 10, imgStartX + deltaX));
            customW = imgStartX + imgStartW - customX;
            customH = Math.max(10, Math.min(448 - customY, imgStartH + deltaY));
        } else if (activeDragAction === 'resize-TR') {
            customY = Math.max(0, Math.min(imgStartY + imgStartH - 10, imgStartY + deltaY));
            customH = imgStartY + imgStartH - customY;
            customW = Math.max(10, Math.min(768 - customX, imgStartW + deltaX));
        } else if (activeDragAction === 'resize-TL') {
            customX = Math.max(0, Math.min(imgStartX + imgStartW - 10, imgStartX + deltaX));
            customY = Math.max(0, Math.min(imgStartY + imgStartH - 10, imgStartY + deltaY));
            customW = imgStartX + imgStartW - customX;
            customH = imgStartY + imgStartH - customY;
        }
        
        clampCustomBounds();
        updateSliders();
        drawDraftPreview();
        isApplied = false;
    }

    function handleCanvasMouseUp() {
        isDraggingImage = false;
        activeDragAction = null;
        document.removeEventListener('mousemove', handleCanvasMouseMove);
        document.removeEventListener('mouseup', handleCanvasMouseUp);
    }

    function handleCanvasMouseMoveNoDrag(e) {
        if (layoutMode !== 'custom' || isDraggingImage || !draftImg) return;
        const pCanvas = document.getElementById('db-preview-canvas');
        if (!pCanvas) return;
        const rect = pCanvas.getBoundingClientRect();
        
        const mouseX = e.clientX - rect.left;
        const mouseY = e.clientY - rect.top;
        
        const workX = (mouseX / rect.width) * 768;
        const workY = (mouseY / rect.height) * 448;
        
        // Match hover cursor detection threshold (24 screen pixels):
        const handleThreshold = 24 * (768 / rect.width);
        
        const distTL = Math.hypot(workX - customX, workY - customY);
        const distTR = Math.hypot(workX - (customX + customW), workY - customY);
        const distBL = Math.hypot(workX - customX, workY - (customY + customH));
        const distBR = Math.hypot(workX - (customX + customW), workY - (customY + customH));
        
        if (distTL < handleThreshold || distBR < handleThreshold) {
            pCanvas.style.cursor = 'nwse-resize';
        } else if (distTR < handleThreshold || distBL < handleThreshold) {
            pCanvas.style.cursor = 'nesw-resize';
        } else if (workX >= customX && workX <= customX + customW &&
                   workY >= customY && workY <= customY + customH) {
            pCanvas.style.cursor = 'move';
        } else {
            pCanvas.style.cursor = 'default';
        }
    }

    function rgbToHex(r, g, b) {
        return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase();
    }

    function hexToRgb(hex) {
        const bigint = parseInt(hex.slice(1), 16);
        return {
            r: (bigint >> 16) & 255,
            g: (bigint >> 8) & 255,
            b: bigint & 255
        };
    }

    function getDistance(c1, c2) {
        const rMean = (c1.r + c2.r) / 2;
        const r = c1.r - c2.r;
        const g = c1.g - c2.g;
        const b = c1.b - c2.b;
        return (2 + rMean / 256) * r * r + 4 * g * g + (2 + (255 - rMean) / 256) * b * b;
    }

    function quantize(pixels, maxColors) {
        if (pixels.length <= maxColors) {
            return pixels.map(p => ({ r: p.r, g: p.g, b: p.b }));
        }

        pixels.sort((a, b) => b.count - a.count);

        let clusters = [];
        let threshold = 35 * 35 * 3; // Adjusted for redmean perceptual distance scale

        for (let p of pixels) {
            let added = false;
            for (let c of clusters) {
                if (getDistance(p, c.center) < threshold) {
                    c.sumR += p.r * p.count;
                    c.sumG += p.g * p.count;
                    c.sumB += p.b * p.count;
                    c.totalCount += p.count;
                    c.center.r = Math.round(c.sumR / c.totalCount);
                    c.center.g = Math.round(c.sumG / c.totalCount);
                    c.center.b = Math.round(c.sumB / c.totalCount);
                    added = true;
                    break;
                }
            }

            if (!added) {
                if (clusters.length < maxColors) {
                    clusters.push({
                        center: { r: p.r, g: p.g, b: p.b },
                        sumR: p.r * p.count,
                        sumG: p.g * p.count,
                        sumB: p.b * p.count,
                        totalCount: p.count
                    });
                } else {
                    let closest = null;
                    let minDist = Infinity;
                    for (let c of clusters) {
                        let dist = getDistance(p, c.center);
                        if (dist < minDist) {
                            minDist = dist;
                            closest = c;
                        }
                    }
                    closest.sumR += p.r * p.count;
                    closest.sumG += p.g * p.count;
                    closest.sumB += p.b * p.count;
                    closest.totalCount += p.count;
                    closest.center.r = Math.round(closest.sumR / closest.totalCount);
                    closest.center.g = Math.round(closest.sumG / closest.totalCount);
                    closest.center.b = Math.round(closest.sumB / closest.totalCount);
                }
            }
        }

        // K-Means refinement passes (3 iterations)
        let centers = clusters.map(c => ({ r: c.center.r, g: c.center.g, b: c.center.b }));
        for (let iter = 0; iter < 3; iter++) {
            const nextSums = Array.from({ length: centers.length }, () => ({ sumR: 0, sumG: 0, sumB: 0, totalCount: 0 }));

            for (const p of pixels) {
                let closestIdx = 0;
                let minDist = Infinity;
                for (let i = 0; i < centers.length; i++) {
                    const dist = getDistance(p, centers[i]);
                    if (dist < minDist) {
                        minDist = dist;
                        closestIdx = i;
                    }
                }
                const s = nextSums[closestIdx];
                s.sumR += p.r * p.count;
                s.sumG += p.g * p.count;
                s.sumB += p.b * p.count;
                s.totalCount += p.count;
            }

            for (let i = 0; i < centers.length; i++) {
                const s = nextSums[i];
                if (s.totalCount > 0) {
                    centers[i].r = Math.round(s.sumR / s.totalCount);
                    centers[i].g = Math.round(s.sumG / s.totalCount);
                    centers[i].b = Math.round(s.sumB / s.totalCount);
                }
            }
        }

        return centers;
    }

    function loadImage(url) {
        return new Promise((resolve, reject) => {
            const img = new Image();
            img.crossOrigin = 'anonymous';
            img.onload = () => resolve(img);
            img.onerror = () => reject(new Error('Image loading error.'));
            img.src = url;
        });
    }

    function getGameCanvas() {
        const all = [...document.querySelectorAll('canvas')];
        if (!all.length) return null;
        const largest = all.reduce((a, b) => (a.width * a.height >= b.width * b.height ? a : b));
        if (largest.width >= 400) return largest;
        return null;
    }

    function clampInt(n, min, max) {
        return Math.min(max, Math.max(min, n));
    }

    function normalizeFillRects(rects, canvasWidth, canvasHeight) {
        if (!Array.isArray(rects) || canvasWidth <= 0 || canvasHeight <= 0) return [];

        const normalized = [];
        for (const rect of rects) {
            if (!rect) continue;

            const x = Math.round(Number(rect.x));
            const y = Math.round(Number(rect.y));
            const w = Math.round(Number(rect.w));
            const h = Math.round(Number(rect.h));
            if (![x, y, w, h].every(Number.isFinite)) continue;
            if (w < 1 || h < 1) continue;

            let x1 = x;
            let y1 = y;
            let x2 = x + w;
            let y2 = y + h;

            if (x2 <= 0 || y2 <= 0 || x1 >= canvasWidth || y1 >= canvasHeight) continue;

            x1 = clampInt(x1, 0, canvasWidth - 1);
            y1 = clampInt(y1, 0, canvasHeight - 1);
            x2 = clampInt(x2, 1, canvasWidth);
            y2 = clampInt(y2, 1, canvasHeight);

            const nw = x2 - x1;
            const nh = y2 - y1;
            if (nw < 1 || nh < 1) continue;

            normalized.push({ x: x1, y: y1, w: nw, h: nh });
        }

        normalized.sort((a, b) => a.y - b.y || a.x - b.x || a.h - b.h || a.w - b.w);
        return normalized;
    }

    function buildFillBatchFrame(strokeId, hexColor, rects) {
        if (!Number.isInteger(strokeId) || strokeId < 0) return null;
        if (!HEX_COLOR_RE.test(hexColor)) return null;
        if (!Array.isArray(rects) || rects.length === 0) return null;

        const v = [8, strokeId, [hexColor.toUpperCase(), 10]];

        for (let i = 0; i < rects.length; i++) {
            const rect = rects[i];
            if (
                !rect ||
                !Number.isInteger(rect.x) ||
                !Number.isInteger(rect.y) ||
                !Number.isInteger(rect.w) ||
                !Number.isInteger(rect.h) ||
                (rect.parentIndex !== undefined && !Number.isInteger(rect.parentIndex)) ||
                rect.w < 1 ||
                rect.h < 1
            ) {
                return null;
            }
            // For backward compatibility with old-style rects, calculate parentIndex
            const parentIndex = (rect.parentIndex !== undefined) ? rect.parentIndex : (i === 0 ? 0 : (i - 1) * 5);
            v.push(rect.x, rect.y, rect.w, rect.h, parentIndex);
        }

        if ((v.length - 3) % 5 !== 0) return null;

        const packet = {
            t: CFG.currentTurnId,
            d: 1,
            v
        };
        const frame = `${wsPrefix}${JSON.stringify(packet)}]`;
        if (!frame.startsWith('42[')) return null;

        return { frame };
    }

    function getCanvasSampleHash(canvas) {
        try {
            if (!canvas || !canvas.width || !canvas.height) return null;
            const ctx = canvas.getContext('2d');
            if (!ctx) return null;

            const { width, height } = canvas;
            const data = ctx.getImageData(0, 0, width, height).data;
            const cols = 12;
            const rows = 8;

            let hash = 2166136261;
            for (let sy = 0; sy < rows; sy++) {
                const y = Math.min(height - 1, Math.floor((sy + 0.5) * height / rows));
                for (let sx = 0; sx < cols; sx++) {
                    const x = Math.min(width - 1, Math.floor((sx + 0.5) * width / cols));
                    const idx = (y * width + x) * 4;
                    hash ^= data[idx];
                    hash = Math.imul(hash, 16777619);
                    hash ^= data[idx + 1];
                    hash = Math.imul(hash, 16777619);
                    hash ^= data[idx + 2];
                    hash = Math.imul(hash, 16777619);
                }
            }
            return hash >>> 0;
        } catch (err) {
            console.warn('[DrawBot] Could not read canvas for fast-fill check:', err);
            return null;
        }
    }

    async function checkCanvasChanged(canvas, baselineHash) {
        if (baselineHash === null || baselineHash === undefined) return null;
        await sleep(Math.max(100, CFG.packetDelay * 2 + 40));
        const nextHash = getCanvasSampleHash(canvas);
        if (nextHash === null || nextHash === undefined) return null;
        return nextHash !== baselineHash;
    }

    function normalizeHexColorOrDefault(input, fallback = '#FF0013') {
        const hex = String(input || '').trim().toUpperCase();
        if (HEX_COLOR_RE.test(hex)) return hex;
        return fallback;
    }


    // --- Optimized Fill Geometry Engine (Numeric Keys + DFS + Area-Maximizing Rects) ---

    function findMaximalRectangleOpt(startX, startY, pixelSet, gridWidth, height) {
        const startKey = startY * gridWidth + startX;
        if (pixelSet[startKey] !== 1) return null;

        // Strategy 1: expand RIGHT first, then DOWN (allowing width to narrow for max area)
        let w1 = 0;
        while (startX + w1 < gridWidth && pixelSet[startY * gridWidth + startX + w1] === 1) w1++;
        let bestW1 = w1, bestH1 = 1, bestArea1 = w1;
        let currW = w1;
        for (let dy = 1; startY + dy < height; dy++) {
            const rowBase = (startY + dy) * gridWidth + startX;
            let rowW = 0;
            while (rowW < currW && pixelSet[rowBase + rowW] === 1) rowW++;
            if (rowW < 1) break;
            currW = rowW;
            const area = currW * (dy + 1);
            if (area > bestArea1) { bestArea1 = area; bestW1 = currW; bestH1 = dy + 1; }
        }

        // Strategy 2: expand DOWN first, then RIGHT (allowing height to narrow for max area)
        let h2 = 0;
        while (startY + h2 < height && pixelSet[(startY + h2) * gridWidth + startX] === 1) h2++;
        let bestW2 = 1, bestH2 = h2, bestArea2 = h2;
        let currH = h2;
        for (let dx = 1; startX + dx < gridWidth; dx++) {
            let colH = 0;
            while (colH < currH && pixelSet[(startY + colH) * gridWidth + startX + dx] === 1) colH++;
            if (colH < 1) break;
            currH = colH;
            const area = (dx + 1) * currH;
            if (area > bestArea2) { bestArea2 = area; bestW2 = dx + 1; bestH2 = currH; }
        }

        // Pick the strategy with larger area
        const bestW = bestArea1 >= bestArea2 ? bestW1 : bestW2;
        const bestH = bestArea1 >= bestArea2 ? bestH1 : bestH2;

        // Remove consumed pixels from set
        let removed = 0;
        for (let dy = 0; dy < bestH; dy++) {
            const rowBase = (startY + dy) * gridWidth + startX;
            for (let dx = 0; dx < bestW; dx++) {
                if (pixelSet[rowBase + dx] === 1) {
                    pixelSet[rowBase + dx] = 0;
                    removed++;
                }
            }
        }

        return { x: startX, y: startY, w: bestW, h: bestH, removed };
    }

    function buildRectangleTreeDFS(pixelSet, initialPixelCount, gridWidth, height) {
        const rects = [];
        const stack = []; // DFS stack
        let pixelCount = initialPixelCount;
        let lastSearchIdx = 0;

        while (pixelCount > 0) {
            // Find next active pixel index
            while (lastSearchIdx < pixelSet.length && pixelSet[lastSearchIdx] !== 1) {
                lastSearchIdx++;
            }
            if (lastSearchIdx >= pixelSet.length) break;

            const startX = lastSearchIdx % gridWidth;
            const startY = Math.floor(lastSearchIdx / gridWidth);

            const rect = findMaximalRectangleOpt(startX, startY, pixelSet, gridWidth, height);
            if (!rect) break;
            pixelCount -= rect.removed;

            rect.parentRef = null; // Root of a new island
            rects.push(rect);
            stack.push(rect);

            // DFS traversal
            while (stack.length > 0) {
                const currentRect = stack.pop();
                const candidates = [];

                // Top border: y - 1
                if (currentRect.y > 0) {
                    const rowBase = (currentRect.y - 1) * gridWidth;
                    for (let px = currentRect.x; px < currentRect.x + currentRect.w; px++) {
                        if (pixelSet[rowBase + px] === 1) {
                            candidates.push(px, currentRect.y - 1);
                        }
                    }
                }

                // Bottom border: y + h
                if (currentRect.y + currentRect.h < height) {
                    const rowBase = (currentRect.y + currentRect.h) * gridWidth;
                    for (let px = currentRect.x; px < currentRect.x + currentRect.w; px++) {
                        if (pixelSet[rowBase + px] === 1) {
                            candidates.push(px, currentRect.y + currentRect.h);
                        }
                    }
                }

                // Left border: x - 1
                if (currentRect.x > 0) {
                    const checkX = currentRect.x - 1;
                    for (let py = currentRect.y; py < currentRect.y + currentRect.h; py++) {
                        if (pixelSet[py * gridWidth + checkX] === 1) {
                            candidates.push(checkX, py);
                        }
                    }
                }

                // Right border: x + w
                if (currentRect.x + currentRect.w < gridWidth) {
                    const checkX = currentRect.x + currentRect.w;
                    for (let py = currentRect.y; py < currentRect.y + currentRect.h; py++) {
                        if (pixelSet[py * gridWidth + checkX] === 1) {
                            candidates.push(checkX, py);
                        }
                    }
                }

                // Process candidates (stored as flat x,y pairs)
                for (let ci = 0; ci < candidates.length; ci += 2) {
                    const cx = candidates[ci];
                    const cy = candidates[ci + 1];
                    if (pixelSet[cy * gridWidth + cx] !== 1) continue; // Already consumed

                    const newRect = findMaximalRectangleOpt(cx, cy, pixelSet, gridWidth, height);
                    if (!newRect) continue;
                    pixelCount -= newRect.removed;

                    newRect.parentRef = currentRect;
                    rects.push(newRect);
                    stack.push(newRect);
                }
            }
        }

        return rects;
    }

    function countIslands(colorGrid, width, height) {
        const visited = new Uint8Array(width * height);
        let count = 0;
        const queue = new Int32Array(width * height);

        for (let y = 0; y < height; y++) {
            const rowBase = y * width;
            for (let x = 0; x < width; x++) {
                const key = rowBase + x;
                if (colorGrid[key] && !visited[key]) {
                    count++;
                    let head = 0;
                    let tail = 0;
                    queue[tail++] = key;
                    visited[key] = 1;

                    while (head < tail) {
                        const currKey = queue[head++];
                        const cx = currKey % width;
                        const cy = Math.floor(currKey / width);

                        const dirs = [0, 1, 0, -1, 1, 0, -1, 0];
                        for (let d = 0; d < 8; d += 2) {
                            const nx = cx + dirs[d];
                            const ny = cy + dirs[d + 1];
                            if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
                                const nKey = ny * width + nx;
                                if (colorGrid[nKey] && !visited[nKey]) {
                                    visited[nKey] = 1;
                                    queue[tail++] = nKey;
                                }
                            }
                        }
                    }
                }
            }
        }
        return count;
    }

    function applyBridging(colorGrid, colorIndexGrid, width, height, hexColor, colorsToDraw, maxBridgeLength = 8) {
        const indexC = colorsToDraw.indexOf(hexColor);
        if (indexC === -1) return;

        // 1. Create bridgeable map from colorIndexGrid
        const isBridgeable = new Uint8Array(width * height);
        for (let y = 0; y < height; y++) {
            const rowBase = y * width;
            for (let x = 0; x < width; x++) {
                const key = rowBase + x;
                const idx = colorIndexGrid[key];
                if (idx === indexC || idx > indexC) {
                    isBridgeable[key] = 1;
                }
            }
        }

        // 2. Find connected components of colorGrid
        const visited = new Uint8Array(width * height);
        const components = [];
        const pixelToComp = new Int32Array(width * height).fill(-1);
        const compQueue = new Int32Array(width * height);

        for (let y = 0; y < height; y++) {
            const rowBase = y * width;
            for (let x = 0; x < width; x++) {
                const startKey = rowBase + x;
                if (colorGrid[startKey] && !visited[startKey]) {
                    const compIdx = components.length;
                    const comp = [];
                    let head = 0;
                    let tail = 0;
                    compQueue[tail++] = startKey;
                    visited[startKey] = 1;

                    while (head < tail) {
                        const currKey = compQueue[head++];
                        comp.push(currKey);
                        pixelToComp[currKey] = compIdx;

                        const cx = currKey % width;
                        const cy = Math.floor(currKey / width);

                        const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
                        for (const [dx, dy] of dirs) {
                            const nx = cx + dx;
                            const ny = cy + dy;
                            if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
                                const nKey = ny * width + nx;
                                if (colorGrid[nKey] && !visited[nKey]) {
                                    visited[nKey] = 1;
                                    compQueue[tail++] = nKey;
                                }
                            }
                        }
                    }
                    components.push(comp);
                }
            }
        }

        if (components.length <= 1) return;

        // Parent pointer array for union-find on components
        const parent = Array(components.length).fill(0).map((_, i) => i);
        function find(i) {
            let root = i;
            while (parent[root] !== root) root = parent[root];
            let curr = i;
            while (curr !== root) {
                const next = parent[curr];
                parent[curr] = root;
                curr = next;
            }
            return root;
        }
        function union(i, j) {
            const rootI = find(i);
            const rootJ = find(j);
            if (rootI !== rootJ) {
                parent[rootI] = rootJ;
                return true;
            }
            return false;
        }

        // Multi-source BFS structures
        const bfsQueue = new Int32Array(width * height);
        const dist = new Int16Array(width * height).fill(-1);
        const origin = new Int32Array(width * height).fill(-1);
        const prev = new Int32Array(width * height).fill(-1);
        let tail = 0;

        // Initialize queue with all boundary pixels of all components
        for (let c = 0; c < components.length; c++) {
            for (const key of components[c]) {
                const px = key % width;
                const py = Math.floor(key / width);
                let isBoundary = false;
                const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
                for (const [dx, dy] of dirs) {
                    const nx = px + dx;
                    const ny = py + dy;
                    if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
                        if (!colorGrid[ny * width + nx]) {
                            isBoundary = true;
                            break;
                        }
                    } else {
                        isBoundary = true;
                        break;
                    }
                }
                if (isBoundary) {
                    bfsQueue[tail++] = key;
                    dist[key] = 0;
                    origin[key] = c;
                }
            }
        }

        let head = 0;
        while (head < tail) {
            const currKey = bfsQueue[head++];
            const currDist = dist[currKey];
            const currOrigin = origin[currKey];

            if (currDist >= maxBridgeLength) continue;

            const cx = currKey % width;
            const cy = Math.floor(currKey / width);

            const dirs = [[0, 1], [0, -1], [1, 0], [-1, 0]];
            for (const [dx, dy] of dirs) {
                const nx = cx + dx;
                const ny = cy + dy;
                if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
                    const nKey = ny * width + nx;
                    if (isBridgeable[nKey]) {
                        if (dist[nKey] === -1) {
                            dist[nKey] = currDist + 1;
                            origin[nKey] = currOrigin;
                            prev[nKey] = currKey;
                            bfsQueue[tail++] = nKey;
                        } else {
                            const otherOrigin = origin[nKey];
                            if (find(currOrigin) !== find(otherOrigin)) {
                                if (currDist + dist[nKey] <= maxBridgeLength) {
                                    union(currOrigin, otherOrigin);

                                    // Backtrack from currKey
                                    let k1 = currKey;
                                    while (k1 !== -1 && dist[k1] > 0) {
                                        colorGrid[k1] = 1;
                                        k1 = prev[k1];
                                    }

                                    // Backtrack from nKey
                                    let k2 = nKey;
                                    while (k2 !== -1 && dist[k2] > 0) {
                                        colorGrid[k2] = 1;
                                        k2 = prev[k2];
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    function splitIntoChunks(rects, hexColor, startStrokeId, maxRectsPerChunk) {
        const chunks = [];
        let currentChunkRects = [];
        let rectToLocalIndex = new Map();
        let currentStrokeId = startStrokeId;
        const chunkLimit = (Number.isInteger(maxRectsPerChunk) && maxRectsPerChunk > 0) ? maxRectsPerChunk : Infinity;

        // Base length of packet serialization:
        // wsPrefix + '{"t":' + turnId + ',"d":1,"v":[8,' + strokeId + ',["' + hexColor + '",10]]}' + ']'
        const baseLength = wsPrefix.length + 50 + hexColor.length;
        let currentEstimatedLength = baseLength;

        for (let i = 0; i < rects.length; i++) {
            const rect = rects[i];

            const currentOrig = rect.originalRect || rect;
            const parentOrig = currentOrig.parentRef;

            let parentIndex;
            let shouldForceSplit = false;

            if (parentOrig === null || parentOrig === undefined) {
                parentIndex = 0;
                if (currentChunkRects.length > 0) {
                    shouldForceSplit = true;
                }
            } else if (rectToLocalIndex.has(parentOrig)) {
                parentIndex = rectToLocalIndex.get(parentOrig) * 5;
            } else {
                parentIndex = 0;
                if (currentChunkRects.length > 0) {
                    shouldForceSplit = true;
                }
            }

            const scaledRect = {
                x: rect.x,
                y: rect.y,
                w: rect.w,
                h: rect.h,
                parentIndex: parentIndex
            };

            const rectStrLength =
                String(scaledRect.x).length +
                String(scaledRect.y).length +
                String(scaledRect.w).length +
                String(scaledRect.h).length +
                String(scaledRect.parentIndex).length + 5;

            if (shouldForceSplit ||
                currentChunkRects.length >= chunkLimit ||
                (currentEstimatedLength + rectStrLength > FAST_FILL_MAX_FRAME_CHARS)) {

                if (currentChunkRects.length > 0) {
                    const res = buildFillBatchFrame(currentStrokeId, hexColor, currentChunkRects);
                    const frame = res ? res.frame : null;
                    chunks.push({ frame, strokeId: currentStrokeId, rectCount: currentChunkRects.length, rects: [...currentChunkRects] });
                    currentStrokeId++;
                }

                scaledRect.parentIndex = 0;
                currentChunkRects = [scaledRect];
                rectToLocalIndex.clear();
                rectToLocalIndex.set(currentOrig, 0);
                currentEstimatedLength = baseLength +
                    String(scaledRect.x).length +
                    String(scaledRect.y).length +
                    String(scaledRect.w).length +
                    String(scaledRect.h).length +
                    String(0).length + 5;
                continue;
            }

            currentChunkRects.push(scaledRect);
            rectToLocalIndex.set(currentOrig, currentChunkRects.length - 1);
            currentEstimatedLength += rectStrLength;
        }

        if (currentChunkRects.length > 0) {
            const res = buildFillBatchFrame(currentStrokeId, hexColor, currentChunkRects);
            const frame = res ? res.frame : null;
            chunks.push({ frame, strokeId: currentStrokeId, rectCount: currentChunkRects.length, rects: [...currentChunkRects] });
        }

        return chunks;
    }

    // Grid-based version: optimized with numeric keys + DFS + area-maximizing rects
    function generateFillBatchesFromGrid(colorGrid, width, height, hexColor, startStrokeId, step, scaleX, scaleY, canvas, maxRectsPerChunk) {
        const t0 = performance.now();

        // colorGrid is already a flat Uint8Array, so just duplicate it
        const pixelSet = new Uint8Array(colorGrid);
        let pixelCount = 0;
        for (let i = 0; i < pixelSet.length; i++) {
            if (pixelSet[i] === 1) pixelCount++;
        }

        if (pixelCount === 0) return { frames: [], nextStrokeId: startStrokeId };

        const initialPixelCount = pixelCount;

        // Generate rectangles with parent tree (DFS, flat array keys, optimized rect finding)
        const rects = buildRectangleTreeDFS(pixelSet, pixelCount, width, height);
        if (rects.length === 0) return { frames: [], nextStrokeId: startStrokeId };

        const t1 = performance.now();
        console.log(`[DrawBot] Optimization: ${pixelCount} px → ${rects.length} rects in ${(t1 - t0).toFixed(1)}ms`);

        // Map rects to canvas coordinates (preserve DFS order for parent chain)
        const canvasRects = [];
        for (const rect of rects) {
            const sx1 = Math.round(rect.x * step * scaleX);
            const sy1 = Math.round(rect.y * step * scaleY);
            const sx2 = Math.round((rect.x + rect.w) * step * scaleX);
            const sy2 = Math.round((rect.y + rect.h) * step * scaleY);

            const clampedRect = {
                x: Math.max(0, Math.min(sx1, canvas.width - 1)),
                y: Math.max(0, Math.min(sy1, canvas.height - 1)),
                w: Math.max(1, sx2 - sx1),
                h: Math.max(1, sy2 - sy1),
                originalRect: rect
            };

            if (clampedRect.x + clampedRect.w <= 0 ||
                clampedRect.y + clampedRect.h <= 0 ||
                clampedRect.x >= canvas.width ||
                clampedRect.y >= canvas.height) {
                continue;
            }

            canvasRects.push(clampedRect);
        }

        if (canvasRects.length === 0) return { frames: [], nextStrokeId: startStrokeId };

        const chunks = splitIntoChunks(canvasRects, hexColor, startStrokeId, maxRectsPerChunk);
        const frames = chunks.map(chunk => ({
            frame: chunk.frame,
            strokeId: chunk.strokeId,
            rectCount: chunk.rectCount,
            hexColor: hexColor,
            rects: chunk.rects
        }));

        const t2 = performance.now();
        console.log(`[DrawBot] Batching: ${canvasRects.length} rects → ${chunks.length} packets in ${(t2 - t1).toFixed(1)}ms`);

        return {
            frames: frames,
            nextStrokeId: startStrokeId + chunks.length
        };
    }

    // --- Geometry Optimization ---
    function findMaxRectangles(grid, width, height, minW, minH) {
        const rects = [];
        const avail = new Uint8Array(grid); // copy flat grid

        for (let y = 0; y < height; y++) {
            const rowBase = y * width;
            for (let x = 0; x < width; x++) {
                if (!avail[rowBase + x]) continue;

                let maxW = 0;
                while (x + maxW < width && avail[rowBase + x + maxW]) maxW++;

                let bestW = maxW, bestH = 1, bestArea = maxW;
                let currW = maxW;

                for (let h = 2; y + h <= height; h++) {
                    const nextRowBase = (y + h - 1) * width;
                    let rowW = 0;
                    while (rowW < currW && x + rowW < width && avail[nextRowBase + x + rowW]) rowW++;
                    currW = rowW;
                    if (currW < minW) break;

                    let area = currW * h;
                    if (area > bestArea) {
                        bestArea = area;
                        bestW = currW;
                        bestH = h;
                    }
                }

                if (bestW >= minW && bestH >= minH) {
                    rects.push({ x, y, width: bestW, height: bestH });
                    for (let dy = 0; dy < bestH; dy++) {
                        const targetRowBase = (y + dy) * width;
                        for (let dx = 0; dx < bestW; dx++) {
                            avail[targetRowBase + x + dx] = 0;
                        }
                    }
                }
            }
        }
        return { rects, remainingGrid: avail };
    }

    function findPaths(avail, width, height) {
        const paths = [];
        for (let y = 0; y < height; y++) {
            const rowBase = y * width;
            for (let x = 0; x < width; x++) {
                const key = rowBase + x;
                if (!avail[key]) continue;

                const path = [];
                let cx = x, cy = y;
                path.push({ x: cx, y: cy });
                avail[cy * width + cx] = 0;

                let lastDx = 0, lastDy = 0;

                while (true) {
                    let nextDx = 0, nextDy = 0, found = false;

                    if (lastDx !== 0 || lastDy !== 0) {
                        let nx = cx + lastDx, ny = cy + lastDy;
                        if (nx >= 0 && nx < width && ny >= 0 && ny < height && avail[ny * width + nx]) {
                            nextDx = lastDx; nextDy = lastDy; found = true;
                        }
                    }

                    if (!found) {
                        const dxs = [1, -1, 0, 0, 1, -1, 1, -1];
                        const dys = [0, 0, 1, -1, 1, 1, -1, -1];
                        for (let i = 0; i < 8; i++) {
                            let nx = cx + dxs[i], ny = cy + dys[i];
                            if (nx >= 0 && nx < width && ny >= 0 && ny < height && avail[ny * width + nx]) {
                                nextDx = dxs[i]; nextDy = dys[i]; found = true;
                                break;
                            }
                        }
                    }

                    if (!found) break;

                    cx += nextDx; cy += nextDy;
                    path.push({ x: cx, y: cy });
                    avail[cy * width + cx] = 0;
                    lastDx = nextDx; lastDy = nextDy;
                }
                paths.push(path);
            }
        }
        return paths;
    }

    function simplifyPath(path) {
        if (path.length <= 2) return path;
        const simplified = [path[0]];
        for (let i = 1; i < path.length - 1; i++) {
            const prev = path[i - 1], curr = path[i], next = path[i + 1];
            const isCollinear = (curr.x - prev.x) * (next.y - curr.y) === (next.x - prev.x) * (curr.y - prev.y);
            if (!isCollinear) simplified.push(curr);
        }
        simplified.push(path[path.length - 1]);
        return simplified;
    }

    // --- Packet Sending Functions ---
    async function safeSendWithRetry(frame) {
        if (!wsInstance || !frame || cancelFlag) return false;
        const attempts = 3;
        for (let i = 0; i < attempts; i++) {
            try {
                if (wsInstance.readyState !== 1) {
                    throw new Error(`WebSocket is not in OPEN state (readyState: ${wsInstance.readyState})`);
                }
                originalSend.call(wsInstance, frame);
                return true;
            } catch (e) {
                console.warn(`[DrawBot] Packet send failed (attempt ${i + 1}/${attempts}):`, e);
                if (i === attempts - 1 || cancelFlag) {
                    return false;
                }
                await sleep(150);
            }
        }
        return false;
    }

    async function sendRectPacket(strokeId, hexColor, thickness, x1, y1, x2, y2) {
        if (!wsInstance || cancelFlag) return;
        const rectPacket = {
            t: CFG.currentTurnId,
            d: 1,
            v: [6, strokeId, [hexColor, thickness, 10], x1, y1, x2, y2]
        };
        const frame = `${wsPrefix}${JSON.stringify(rectPacket)}]`;
        console.log('[DrawBot] Sent rectangle packet (Tool 6):', frame);
        await safeSendWithRetry(frame);
        await sleep(CFG.packetDelay);
    }

    async function sendFillPacket(strokeId, hexColor, width, height) {
        if (!wsInstance || cancelFlag) return;
        const fillPacket = {
            t: CFG.currentTurnId,
            d: 1,
            v: [8, strokeId, [hexColor, 10], 0, 0, width, height, 0]
        };
        const frame = `${wsPrefix}${JSON.stringify(fillPacket)}]`;
        console.log('[DrawBot] Sent background fill packet (Tool 8):', frame);
        await safeSendWithRetry(frame);
        await sleep(CFG.packetDelay);
    }

    async function sendFillBatchPacket(frame, rectCount, delayMs) {
        if (!wsInstance || !frame || cancelFlag) return false;
        const delay = (typeof delayMs === 'number' && delayMs >= 0) ? delayMs : CFG.packetDelay;
        console.log(`[DrawBot] Sent Tool 8 packet (rects: ${rectCount}, length: ${frame.length}, delay: ${delay}ms)`);
        const sent = await safeSendWithRetry(frame);
        await sleep(delay);
        return sent;
    }

    async function sendStrokePackets(strokeId, hexColor, points, thickness) {
        if (!wsInstance || points.length === 0 || cancelFlag) return;

        const start = points[0];
        const startPacket = {
            t: CFG.currentTurnId,
            d: 1,
            v: [1, strokeId, [hexColor, thickness, 10], start.x, start.y]
        };
        const startFrame = `${wsPrefix}${JSON.stringify(startPacket)}]`;
        console.log('[DrawBot] Sent line start packet (Tool 1):', startFrame);
        await safeSendWithRetry(startFrame);
        await sleep(CFG.packetDelay);

        if (points.length > 1 && !cancelFlag) {
            const vArray = [1, strokeId, [hexColor, thickness, 10], start.x, start.y];
            for (let i = 1; i < points.length; i++) {
                vArray.push(points[i].x - points[i - 1].x);
                vArray.push(points[i].y - points[i - 1].y);
            }
            const movePacket = {
                t: CFG.currentTurnId,
                d: 3,
                v: vArray
            };
            const moveFrame = `${wsPrefix}${JSON.stringify(movePacket)}]`;
            console.log('[DrawBot] Sent line move packet (Tool 1):', moveFrame);
            await safeSendWithRetry(moveFrame);
            await sleep(CFG.packetDelay);
        }
    }

    // --- Image Preprocessing & Analysis ---
    function processImage(img, step, colorsMode, denoiseLevel) {
        const w = Math.round(768 / step);
        const h = Math.round(448 / step);

        const tempCanvas = document.createElement('canvas');
        tempCanvas.width = w;
        tempCanvas.height = h;
        const ctx = tempCanvas.getContext('2d');
        ctx.clearRect(0, 0, w, h);
        
        if (layoutMode === 'stretch') {
            ctx.drawImage(img, 0, 0, w, h);
        } else if (layoutMode === 'center') {
            const imgRatio = img.width / img.height;
            const canvasRatio = 768 / 448;
            let dw, dh, dx, dy;
            if (imgRatio > canvasRatio) {
                dw = w;
                dh = w / imgRatio;
            } else {
                dh = h;
                dw = h * imgRatio;
            }
            dx = (w - dw) / 2;
            dy = (h - dh) / 2;
            ctx.drawImage(img, dx, dy, dw, dh);
        } else if (layoutMode === 'custom') {
            const cx = customX / step;
            const cy = customY / step;
            const cw = customW / step;
            const ch = customH / step;
            ctx.drawImage(img, cx, cy, cw, ch);
        }

        const imgData = ctx.getImageData(0, 0, w, h);
        const data = imgData.data;

        // Use 24-bit integers instead of hex string conversions inside 344k loop
        const colorCounts = new Map();
        for (let i = 0; i < data.length; i += 4) {
            const a = data[i + 3];
            if (a < 150) {
                continue; // Skip transparent/margins
            }
            const rgbInt = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2];
            colorCounts.set(rgbInt, (colorCounts.get(rgbInt) || 0) + 1);
        }

        let palette = [];
        if (colorsMode === 'gartic') {
            palette = GARTIC_PALETTE;
        } else if (colorsMode === 'infinite') {
            palette = Array.from(colorCounts.keys()).map(rgbInt => ({
                r: (rgbInt >> 16) & 255,
                g: (rgbInt >> 8) & 255,
                b: rgbInt & 255
            }));
        } else {
            const maxColors = parseInt(colorsMode, 10);
            const uniqueColors = Array.from(colorCounts.entries()).map(([rgbInt, count]) => ({
                r: (rgbInt >> 16) & 255,
                g: (rgbInt >> 8) & 255,
                b: rgbInt & 255,
                count
            }));
            palette = quantize(uniqueColors, maxColors);
        }

        // Cache hex strings for colors in the palette (typically <= 64, or unique count if infinite)
        const paletteHex = palette.map(c => rgbToHex(c.r, c.g, c.b));
        const whiteIdx = paletteHex.indexOf('#FFFFFF');

        const pixelIndices = new Int16Array(w * h);
        for (let y = 0; y < h; y++) {
            const rowOffset = y * w;
            for (let x = 0; x < w; x++) {
                const idx = (rowOffset + x) * 4;
                const a = data[idx + 3];
                if (a < 150) {
                    pixelIndices[rowOffset + x] = -1; // -1 represents transparent
                } else {
                    let r = data[idx]; let g = data[idx + 1]; let b = data[idx + 2];
                    let closestIdx = 0;
                    let minDist = Infinity;
                    const p = { r, g, b };
                    for (let i = 0; i < palette.length; i++) {
                        const dist = getDistance(p, palette[i]);
                        if (dist < minDist) {
                            minDist = dist;
                            closestIdx = i;
                        }
                    }
                    pixelIndices[rowOffset + x] = closestIdx;
                }
            }
        }

        if (denoiseLevel > 0) {
            const tempIndices = new Int16Array(pixelIndices);
            const counts = new Map();
            for (let y = 0; y < h; y++) {
                const rowOffset = y * w;
                for (let x = 0; x < w; x++) {
                    const idx = rowOffset + x;
                    const colorIdx = pixelIndices[idx];
                    if (colorIdx === -1 || colorIdx === whiteIdx) continue;

                    let sameNeighbors = 0;
                    for (let dx = -1; dx <= 1; dx++) {
                        for (let dy = -1; dy <= 1; dy++) {
                            if (dx === 0 && dy === 0) continue;
                            const nx = x + dx;
                            const ny = y + dy;
                            if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
                                if (pixelIndices[ny * w + nx] === colorIdx) sameNeighbors++;
                            }
                        }
                    }

                    if (sameNeighbors < denoiseLevel) {
                        counts.clear();
                        let maxCount = 0;
                        let bestColorIdx = colorIdx;

                        for (let dx = -1; dx <= 1; dx++) {
                            for (let dy = -1; dy <= 1; dy++) {
                                if (dx === 0 && dy === 0) continue;
                                const nx = x + dx;
                                const ny = y + dy;
                                if (nx >= 0 && nx < w && ny >= 0 && ny < h) {
                                    const nIdx = pixelIndices[ny * w + nx];
                                    if (nIdx === -1) continue; // Skip transparent neighbors/canvas margins
                                    const c = (counts.get(nIdx) || 0) + 1;
                                    counts.set(nIdx, c);
                                    if (c > maxCount) {
                                        maxCount = c;
                                        bestColorIdx = nIdx;
                                    } else if (c === maxCount) {
                                        const c1 = palette[nIdx];
                                        const c2 = palette[bestColorIdx];
                                        const orig = palette[colorIdx];
                                        const d1 = getDistance(c1, orig);
                                        const d2 = getDistance(c2, orig);
                                        if (d1 < d2) {
                                            bestColorIdx = nIdx;
                                        }
                                    }
                                }
                            }
                        }
                        tempIndices[idx] = bestColorIdx;
                    }
                }
            }
            pixelIndices.set(tempIndices);
        }

        const mappedGrid = Array(w).fill(null).map(() => Array(h).fill(null));
        for (let x = 0; x < w; x++) {
            for (let y = 0; y < h; y++) {
                const idx = pixelIndices[y * w + x];
                mappedGrid[x][y] = (idx === -1) ? 'transparent' : paletteHex[idx];
            }
        }

        return { grid: mappedGrid, width: w, height: h };
    }

    function analyzeDrawingCommands(grid, width, height, fillBg, drawMode, batchSize, useBridge) {
        const workingGrid = Array(width).fill(null).map((_, x) => Array(height).fill(null).map((_, y) => grid[x][y]));

        const colors = new Set();
        for (let x = 0; x < width; x++) {
            for (let y = 0; y < height; y++) {
                colors.add(workingGrid[x][y]);
            }
        }

        let totalRects = 0;
        let totalPaths = 0;
        let totalPackets = 0;
        let skippedColor = null;

        if (fillBg && colors.size > 0) {
            const counts = {};
            for (let x = 0; x < width; x++) {
                for (let y = 0; y < height; y++) {
                    const c = workingGrid[x][y];
                    counts[c] = (counts[c] || 0) + 1;
                }
            }
            let maxCount = 0;
            let mostPopularColor = null;
            for (let c in counts) {
                if (c === 'transparent') continue;
                if (counts[c] > maxCount) {
                    maxCount = counts[c];
                    mostPopularColor = c;
                }
            }
            if (mostPopularColor) {
                skippedColor = mostPopularColor;
            }
        }

        const colorsToDraw = Array.from(colors).filter(c => c !== skippedColor && c !== 'transparent');
        let colorIndexGrid = null;
        if (drawMode === 'fill') {
            const islandCounts = new Map();
            for (const color of colorsToDraw) {
                const colorGrid = new Uint8Array(width * height);
                for (let y = 0; y < height; y++) {
                    const rowBase = y * width;
                    for (let x = 0; x < width; x++) {
                        if (workingGrid[x][y] === color) {
                            colorGrid[rowBase + x] = 1;
                        }
                    }
                }
                islandCounts.set(color, countIslands(colorGrid, width, height));
            }
            colorsToDraw.sort((a, b) => islandCounts.get(b) - islandCounts.get(a));

            const colorToIndex = new Map();
            for (let i = 0; i < colorsToDraw.length; i++) {
                colorToIndex.set(colorsToDraw[i], i);
            }
            colorIndexGrid = new Int32Array(width * height);
            for (let y = 0; y < height; y++) {
                const rowBase = y * width;
                for (let x = 0; x < width; x++) {
                    const color = workingGrid[x][y];
                    const idx = colorToIndex.get(color);
                    colorIndexGrid[rowBase + x] = (idx !== undefined) ? idx : -1;
                }
            }
        }

        const canvas = getGameCanvas() || { width: 768, height: 448 };
        const step = parseInt(document.getElementById('db-scale') ? document.getElementById('db-scale').value : '2', 10);
        const scaleX = canvas.width / 768;
        const scaleY = canvas.height / 448;

        for (let color of colors) {
            if (color === skippedColor) continue;

            const colorGrid = new Uint8Array(width * height);
            for (let y = 0; y < height; y++) {
                const rowBase = y * width;
                for (let x = 0; x < width; x++) {
                    if (workingGrid[x][y] === color) {
                        colorGrid[rowBase + x] = 1;
                    }
                }
            }

            if (drawMode === 'fill') {
                if (useBridge) {
                    applyBridging(colorGrid, colorIndexGrid, width, height, color, colorsToDraw, CFG.maxBridgeLength);
                }
                const result = generateFillBatchesFromGrid(colorGrid, width, height, color, 1, step, scaleX, scaleY, canvas, batchSize);
                totalRects += result.frames.reduce((acc, f) => acc + f.rectCount, 0);
                totalPackets += result.frames.length;
            } else {
                const { rects, remainingGrid } = findMaxRectangles(colorGrid, width, height, 2, 2);
                totalRects += rects.length;

                const paths = findPaths(remainingGrid, width, height);
                for (let path of paths) {
                    if (path.length === 1) {
                        totalRects++;
                    } else {
                        const first = path[0];
                        const isHorizontal = path.every(p => p.y === first.y);
                        const isVertical = path.every(p => p.x === first.x);
                        if (isHorizontal || isVertical) {
                            totalRects++;
                        } else {
                            totalPaths++;
                        }
                    }
                }
            }
        }

        let commandCount = 0;
        if (drawMode === 'fill') {
            commandCount = (skippedColor ? 2 : 0) + totalPackets;
        } else {
            commandCount = (skippedColor ? 2 : 0) + totalRects + totalPaths * 1.8;
        }

        return {
            rects: totalRects,
            paths: totalPaths,
            packets: totalPackets,
            fillPacketCount: drawMode === 'fill' ? totalPackets : 0,
            skippedColor,
            totalCommands: Math.round(commandCount)
        };
    }

    async function processAndPreviewImage(src) {
        if (!src) return;
        const infoEl = document.getElementById('db-preview-info');
        infoEl.textContent = 'Processing...';

        try {
            const img = await loadImage(src);
            draftImg = img;
            const step = parseInt(document.getElementById('db-scale').value, 10);
            const colorsMode = document.getElementById('db-colors-mode').value;
            const denoiseLevel = parseInt(document.getElementById('db-denoise-level').value, 10);
            const fillBg = document.getElementById('db-fill-bg').checked;
            const drawMode = document.getElementById('db-draw-mode').value;
            const batchSize = parseInt(document.getElementById('db-batch-size').value, 10) || 3000;
            const useBridge = document.getElementById('db-use-bridge') ? document.getElementById('db-use-bridge').checked : false;
            const bridgeLenInput = document.getElementById('db-bridge-len');
            if (bridgeLenInput) {
                CFG.maxBridgeLength = parseInt(bridgeLenInput.value, 10) || 50;
            }

            const processed = processImage(img, step, colorsMode, denoiseLevel);
            const analysis = analyzeDrawingCommands(processed.grid, processed.width, processed.height, fillBg, drawMode, batchSize, useBridge);

            // Render Preview Canvas
            const pCanvas = document.getElementById('db-preview-canvas');
            pCanvas.width = processed.width;
            pCanvas.height = processed.height;
            const pCtx = pCanvas.getContext('2d');
            const pImgData = pCtx.createImageData(processed.width, processed.height);
            const pData = pImgData.data;

            for (let y = 0; y < processed.height; y++) {
                for (let x = 0; x < processed.width; x++) {
                    const hex = processed.grid[x][y];
                    const idx = (y * processed.width + x) * 4;
                    if (hex === 'transparent') {
                        pData[idx] = 0;
                        pData[idx + 1] = 0;
                        pData[idx + 2] = 0;
                        pData[idx + 3] = 0;
                    } else {
                        const rgb = hexToRgb(hex);
                        pData[idx] = rgb.r;
                        pData[idx + 1] = rgb.g;
                        pData[idx + 2] = rgb.b;
                        pData[idx + 3] = 255;
                    }
                }
            }
            pCtx.putImageData(pImgData, 0, 0);
            document.getElementById('db-preview-container').style.display = 'flex';

            const delay = parseInt(document.getElementById('db-delay')?.value || '127', 10) || 127;
            if (drawMode === 'fill') {
                const fillPPS = parseInt(document.getElementById('db-fill-pps').value, 10) || 8;
                const fillInterval = Math.max(125, Math.ceil(1000 / fillPPS)) + 2;
                const estSec = Math.round((analysis.fillPacketCount * fillInterval) / 1000) + (analysis.skippedColor ? 1 : 0);
                infoEl.innerHTML = `Rects: <b>${analysis.rects}</b> | Packets: <b>${analysis.fillPacketCount}</b><br>Speed: <b>${fillPPS} pkt/s</b> (${fillInterval}ms) | ~<b>${estSec}s</b>`;
            } else {
                const estSec = Math.round((analysis.totalCommands * delay) / 1000);
                infoEl.innerHTML = `Rects: <b>${analysis.rects}</b> | Lines: <b>${analysis.paths}</b><br>Draw time: <b>~${estSec}s</b>`;
            }
            isApplied = true;
        } catch (e) {
            console.error(e);
            infoEl.textContent = 'Loading/preview error';
            isApplied = false;
        }
    }

    async function checkPause() {
        while (isPaused && !cancelFlag) {
            await sleep(100);
        }
    }

    function resetDrawUI() {
        console.log('[DrawBot] resetDrawUI: Starting UI reset.');
        isDrawing = false;
        try {
            const startBtn = document.getElementById('db-start');
            if (startBtn) {
                console.log('[DrawBot] resetDrawUI: Start button found. Previous display:', startBtn.style.display);
                startBtn.style.display = 'block';
            } else {
                console.warn('[DrawBot] resetDrawUI: Start button not found in DOM!');
            }

            const pauseBtn = document.getElementById('db-pause');
            if (pauseBtn) {
                console.log('[DrawBot] resetDrawUI: Pause button found. Previous display:', pauseBtn.style.display);
                pauseBtn.style.display = 'none';
                pauseBtn.textContent = '⏸ Pause';
                pauseBtn.style.background = '#d97706';
            } else {
                console.warn('[DrawBot] resetDrawUI: Pause button not found in DOM!');
            }

            const cancelBtn = document.getElementById('db-cancel');
            if (cancelBtn) {
                console.log('[DrawBot] resetDrawUI: Cancel button found. Previous display:', cancelBtn.style.display);
                cancelBtn.style.display = 'none';
            } else {
                console.warn('[DrawBot] resetDrawUI: Cancel button not found in DOM!');
            }

            const progressWrapper = document.getElementById('db-progress-wrapper');
            if (progressWrapper) {
                console.log('[DrawBot] resetDrawUI: Progress bar found. Previous display:', progressWrapper.style.display);
                progressWrapper.style.display = 'none';
            } else {
                console.warn('[DrawBot] resetDrawUI: Progress bar not found in DOM!');
            }
        } catch (e) {
            console.error('[DrawBot] Error during UI reset:', e);
        }
        console.log('[DrawBot] resetDrawUI: UI reset completed.');
    }

    function updateStatus(msg, color = '#9ca3af') {
        const el = document.getElementById('db-status');
        if (el) {
            el.textContent = msg;
            el.style.color = color;
        }
    }

    // --- Drawing Loop ---
    async function runDraw() {
        if (!wsInstance) {
            alert('WebSocket not found! Try making one manual brush stroke on the canvas to initialize the socket.');
            return;
        }
        if (isDrawing) return;

        const canvas = getGameCanvas();
        if (!canvas) {
            alert('Enter the Gartic Phone drawing mode first!');
            return;
        }

        let strokeId = strokeIdCounter;

        try {
            const step = parseInt(document.getElementById('db-scale').value, 10);
            const colorsMode = document.getElementById('db-colors-mode').value;
            const denoiseLevel = parseInt(document.getElementById('db-denoise-level').value, 10);
            const fillBg = document.getElementById('db-fill-bg').checked;
            const drawMode = document.getElementById('db-draw-mode').value;
            const batchSize = parseInt(document.getElementById('db-batch-size').value, 10) || 3000;
            CFG.packetDelay = parseInt(document.getElementById('db-delay').value, 10);
            CFG.fillPPS = parseInt(document.getElementById('db-fill-pps').value, 10) || 8;
            CFG.useBridge = document.getElementById('db-use-bridge') ? document.getElementById('db-use-bridge').checked : false;
            const bridgeLenInput = document.getElementById('db-bridge-len');
            if (bridgeLenInput) {
                CFG.maxBridgeLength = parseInt(bridgeLenInput.value, 10) || 50;
            }
            const fillInterval = Math.max(125, Math.ceil(1000 / CFG.fillPPS)) + 2;

            const scaleX = canvas.width / 768;
            const scaleY = canvas.height / 448;

            let thickness = step * 4 - 2;

            isDrawing = true;
            cancelFlag = false;
            isPaused = false;
            const drawStartTime = performance.now();

            const startBtn = document.getElementById('db-start');
            if (startBtn) startBtn.style.display = 'none';
            const pauseBtn = document.getElementById('db-pause');
            if (pauseBtn) {
                pauseBtn.style.display = 'block';
                pauseBtn.textContent = '⏸ Pause';
                pauseBtn.style.background = '#d97706';
            }
            const cancelBtn = document.getElementById('db-cancel');
            if (cancelBtn) cancelBtn.style.display = 'block';

            // Reset progress details before drawing starts
            const progressBar = document.getElementById('db-progress-bar');
            if (progressBar) progressBar.style.width = '0%';
            const progressPercent = document.getElementById('db-progress-percent');
            if (progressPercent) progressPercent.textContent = '0%';
            const progressTime = document.getElementById('db-progress-time');
            if (progressTime) progressTime.textContent = 'Remaining: ~0s';

            const progressWrapper = document.getElementById('db-progress-wrapper');
            if (progressWrapper) progressWrapper.style.display = 'block';

            updateStatus('Loading image...', '#fbbf24');

            let img;
            try {
                img = await loadImage(currentImageSrc);
            } catch (e) {
                updateStatus('❌ Loading error', '#ef4444');
                return;
            }

            if (!isApplied) {
                updateStatus('Applying & Rendering Preview...', '#fbbf24');
                await processAndPreviewImage(currentImageSrc);
                if (!isApplied) {
                    updateStatus('❌ Preview render failed', '#ef4444');
                    return;
                }
                // Small delay to let user see preview and est draw time before drawing begins
                await sleep(1500);
            }

            updateStatus('Processing...', '#fbbf24');
            const { grid, width, height } = processImage(img, step, colorsMode, denoiseLevel);

            const colors = new Set();
            for (let x = 0; x < width; x++) {
                for (let y = 0; y < height; y++) {
                    colors.add(grid[x][y]);
                }
            }

            let skippedColor = null;
            if (fillBg && colors.size > 0) {
                const counts = {};
                for (let x = 0; x < width; x++) {
                    for (let y = 0; y < height; y++) {
                        const c = grid[x][y];
                        counts[c] = (counts[c] || 0) + 1;
                    }
                }
                let maxCount = 0;
                let mostPopularColor = null;
                for (let c in counts) {
                    if (c === 'transparent') continue;
                    if (counts[c] > maxCount) {
                        maxCount = counts[c];
                        mostPopularColor = c;
                    }
                }
                if (mostPopularColor) {
                    skippedColor = mostPopularColor;
                }
            }

            const analysis = analyzeDrawingCommands(grid, width, height, fillBg, drawMode, batchSize, CFG.useBridge);
            let commandsSent = 0;
            const totalCommands = analysis.totalCommands || 1;
            let fillModeError = '';
            let fastFillPacketsSent = 0;
            let fastFillProbeChecked = false;
            let fastFillProbeSupported = false;
            let fastFillBaselineHash = null;
            let fastFillVisualCheckInconclusive = false;

            const currentDelay = (drawMode === 'fill') ? fillInterval : CFG.packetDelay;
            function updateProgress() {
                const pct = Math.min(100, Math.round((commandsSent / totalCommands) * 100));
                document.getElementById('db-progress-bar').style.width = `${pct}%`;
                document.getElementById('db-progress-percent').textContent = `${pct}%`;
                const estLeft = Math.max(0, Math.round(((totalCommands - commandsSent) * currentDelay) / 1000));
                document.getElementById('db-progress-time').textContent = `Remaining: ~${estLeft}s`;
            }

            // 1. Fill Background
            if (skippedColor) {
                updateStatus('Filling background...', skippedColor);
                await sleep(150);
                await sendFillPacket(strokeId++, skippedColor, canvas.width, canvas.height);
                await sleep(150);
                await sendFillPacket(strokeId++, skippedColor, canvas.width, canvas.height);

                // Render background on screen in real-time
                try {
                    const ctx = canvas.getContext('2d');
                    ctx.fillStyle = skippedColor;
                    ctx.fillRect(0, 0, canvas.width, canvas.height);
                } catch (e) {
                    console.error('[DrawBot] Error rendering background on canvas:', e);
                }

                commandsSent += 2;
                updateProgress();
            }

            const colorsToDraw = Array.from(colors).filter(c => c !== skippedColor && c !== 'transparent');
            let colorIndexGrid = null;
            if (drawMode === 'fill') {
                const islandCounts = new Map();
                for (const color of colorsToDraw) {
                    const colorGrid = new Uint8Array(width * height);
                    for (let y = 0; y < height; y++) {
                        const rowBase = y * width;
                        for (let x = 0; x < width; x++) {
                            if (grid[x][y] === color) {
                                colorGrid[rowBase + x] = 1;
                            }
                        }
                    }
                    islandCounts.set(color, countIslands(colorGrid, width, height));
                }
                colorsToDraw.sort((a, b) => islandCounts.get(b) - islandCounts.get(a));

                const colorToIndex = new Map();
                for (let i = 0; i < colorsToDraw.length; i++) {
                    colorToIndex.set(colorsToDraw[i], i);
                }
                colorIndexGrid = new Int32Array(width * height);
                for (let y = 0; y < height; y++) {
                    const rowBase = y * width;
                    for (let x = 0; x < width; x++) {
                        const color = grid[x][y];
                        const idx = colorToIndex.get(color);
                        colorIndexGrid[rowBase + x] = (idx !== undefined) ? idx : -1;
                    }
                }
            }
            if (drawMode === 'fill') {
                fastFillBaselineHash = getCanvasSampleHash(canvas);
                fastFillProbeSupported = fastFillBaselineHash !== null && fastFillBaselineHash !== undefined;
                if (!fastFillProbeSupported) {
                    console.warn('[DrawBot] Tool 8 application check unavailable: canvas hash could not be read.');
                }
            }

            let lastPacketTime = performance.now() - fillInterval;
            for (let i = 0; i < colorsToDraw.length; i++) {
                const hex = colorsToDraw[i];
                if (cancelFlag) break;

                // Small delay before switching to new color to prevent packet spam
                if (i > 0) {
                    await sleep(125);
                    if (cancelFlag) break;
                }

                updateStatus(`Color ${i + 1}/${colorsToDraw.length} (${hex})`, hex);

                const colorGrid = new Uint8Array(width * height);
                for (let y = 0; y < height; y++) {
                    const rowBase = y * width;
                    for (let x = 0; x < width; x++) {
                        if (grid[x][y] === hex) {
                            colorGrid[rowBase + x] = 1;
                        }
                    }
                }

                if (drawMode === 'fill') {
                    // --- Fast Fill Mode (Tool 8 with BFS Graph Algorithm) ---
                    if (CFG.useBridge) {
                        applyBridging(colorGrid, colorIndexGrid, width, height, hex, colorsToDraw, CFG.maxBridgeLength);
                    }
                    const result = generateFillBatchesFromGrid(colorGrid, width, height, hex, strokeId, step, scaleX, scaleY, canvas, batchSize);

                    if (result.frames.length === 0) {
                        console.warn(`[DrawBot] Color ${hex} skipped: BFS algorithm generated no rects.`);
                        continue;
                    }

                    try {
                        let fillSendStart = performance.now();
                        let fillSendCount = 0;
                        for (const frameObj of result.frames) {
                            await checkPause();
                            if (cancelFlag) break;

                            const elapsedSinceLast = performance.now() - lastPacketTime;
                            const sleepTime = Math.max(0, fillInterval - elapsedSinceLast);

                            const sent = await sendFillBatchPacket(frameObj.frame, frameObj.rectCount, sleepTime);
                            if (!sent && !cancelFlag) {
                                console.warn('[DrawBot] Tool 8 packet failed to send after retries. Skipping this packet.');
                            }
                            lastPacketTime = performance.now();

                            // Render on screen in real-time
                            try {
                                const ctx = canvas.getContext('2d');
                                ctx.fillStyle = hex;
                                for (const r of frameObj.rects) {
                                    ctx.fillRect(r.x, r.y, r.w, r.h);
                                }
                            } catch (e) {
                                console.error('[DrawBot] Error rendering on canvas:', e);
                            }

                            commandsSent++;
                            fastFillPacketsSent++;
                            fillSendCount++;

                            // Show real-time send speed
                            const elapsed = (performance.now() - fillSendStart) / 1000;
                            const realPPS = elapsed > 0 ? (fillSendCount / elapsed).toFixed(1) : '—';
                            updateStatus(`Color ${i + 1}/${colorsToDraw.length} (${hex}) — ${realPPS} pkt/s`, hex);
                            updateProgress();

                            if (!fastFillProbeChecked && fastFillProbeSupported && !cancelFlag) {
                                fastFillProbeChecked = true;
                                const changed = await checkCanvasChanged(canvas, fastFillBaselineHash);
                                if (changed === false) {
                                    fastFillVisualCheckInconclusive = true;
                                    console.warn('[DrawBot] Local canvas did not change after Tool 8. Continuing: result will be verified at end of round.');
                                }
                            }
                        }
                        strokeId = result.nextStrokeId;
                    } catch (err) {
                        const code = err && err.message ? err.message : '';
                        if (code === 'FAST_FILL_FRAME_TOO_LARGE') {
                            fillModeError = 'Tool 8: frame too large even for a single rectangle';
                        } else if (code === 'FAST_FILL_INVALID_PACKET') {
                            fillModeError = 'Tool 8: invalid packet generated';
                        } else if (code === 'FAST_FILL_SEND_FAILED') {
                            fillModeError = 'Tool 8: packet send failed';
                        } else {
                            fillModeError = 'Tool 8: packet send error';
                        }
                        cancelFlag = true;
                        break;
                    }

                } else {
                    // --- Standard Mode (Lines and Squares) ---
                    // --- Rectangles Phase ---
                    const { rects, remainingGrid } = findMaxRectangles(colorGrid, width, height, 2, 2);
                    for (let r of rects) {
                        await checkPause();
                        if (cancelFlag) break;

                        let sx1 = Math.round(r.x * step * scaleX);
                        let sy1 = Math.round(r.y * step * scaleY);
                        let sx2 = Math.round((r.x + r.width) * step * scaleX);
                        let sy2 = Math.round((r.y + r.height) * step * scaleY);

                        await sendRectPacket(strokeId++, hex, thickness, sx1, sy1, sx2, sy2);

                        // Render on screen in real-time
                        try {
                            const ctx = canvas.getContext('2d');
                            ctx.strokeStyle = hex;
                            ctx.lineWidth = thickness;
                            ctx.lineCap = 'round';
                            ctx.lineJoin = 'round';
                            ctx.beginPath();
                            ctx.moveTo(sx1, sy1);
                            ctx.lineTo(sx2, sy2);
                            ctx.stroke();
                        } catch (e) {
                            console.error('[DrawBot] Error rendering rectangle on canvas:', e);
                        }

                        commandsSent++;
                        updateProgress();
                    }

                    if (cancelFlag) break;

                    // --- Strokes Phase ---
                    const paths = findPaths(remainingGrid, width, height);
                    for (let path of paths) {
                        await checkPause();
                        if (cancelFlag) break;

                        let isRect = false;
                        let rx = 0, ry = 0, rw = 0, rh = 0;

                        if (path.length === 1) {
                            isRect = true;
                            rx = path[0].x;
                            ry = path[0].y;
                            rw = 1;
                            rh = 1;
                        } else {
                            const first = path[0];
                            const isHorizontal = path.every(p => p.y === first.y);
                            const isVertical = path.every(p => p.x === first.x);

                            if (isHorizontal) {
                                isRect = true;
                                const xs = path.map(p => p.x);
                                const minX = Math.min(...xs);
                                const maxX = Math.max(...xs);
                                rx = minX;
                                ry = first.y;
                                rw = maxX - minX + 1;
                                rh = 1;
                            } else if (isVertical) {
                                isRect = true;
                                const ys = path.map(p => p.y);
                                const minY = Math.min(...ys);
                                const maxY = Math.max(...ys);
                                rx = first.x;
                                ry = minY;
                                rw = 1;
                                rh = maxY - minY + 1;
                            }
                        }

                        if (isRect) {
                            let sx1 = Math.round(rx * step * scaleX);
                            let sy1 = Math.round(ry * step * scaleY);
                            let sx2 = Math.round((rx + rw) * step * scaleX);
                            let sy2 = Math.round((ry + rh) * step * scaleY);

                            await sendRectPacket(strokeId++, hex, thickness, sx1, sy1, sx2, sy2);

                            // Render on screen in real-time
                            try {
                                const ctx = canvas.getContext('2d');
                                ctx.strokeStyle = hex;
                                ctx.lineWidth = thickness;
                                ctx.lineCap = 'round';
                                ctx.lineJoin = 'round';
                                ctx.beginPath();
                                ctx.moveTo(sx1, sy1);
                                ctx.lineTo(sx2, sy2);
                                ctx.stroke();
                            } catch (e) {
                                console.error('[DrawBot] Error rendering line on canvas:', e);
                            }

                            commandsSent++;
                            updateProgress();
                        } else {
                            let screenPoints = path.map(p => ({
                                x: Math.round((p.x * step + step / 2) * scaleX),
                                y: Math.round((p.y * step + step / 2) * scaleY)
                            }));
                            screenPoints = simplifyPath(screenPoints);

                            if (screenPoints.length > 0) {
                                await sendStrokePackets(strokeId++, hex, screenPoints, thickness);

                                // Render on screen in real-time
                                try {
                                    const ctx = canvas.getContext('2d');
                                    ctx.strokeStyle = hex;
                                    ctx.lineWidth = thickness;
                                    ctx.lineCap = 'round';
                                    ctx.lineJoin = 'round';
                                    ctx.beginPath();
                                    ctx.moveTo(screenPoints[0].x, screenPoints[0].y);
                                    for (let pIdx = 1; pIdx < screenPoints.length; pIdx++) {
                                        ctx.lineTo(screenPoints[pIdx].x, screenPoints[pIdx].y);
                                    }
                                    ctx.stroke();
                                } catch (e) {
                                    console.error('[DrawBot] Error rendering path on canvas:', e);
                                }

                                commandsSent += screenPoints.length > 1 ? 2 : 1;
                                updateProgress();
                            }
                        }
                    }
                }
            }

            if (!fillModeError && drawMode === 'fill' && colorsToDraw.length > 0 && fastFillPacketsSent === 0 && !cancelFlag) {
                fillModeError = 'Tool 8 did not send any valid packets';
            }

            if (fillModeError) {
                updateStatus(`❌ ${fillModeError}`, '#ef4444');
            } else if (cancelFlag) {
                const elapsedSec = ((performance.now() - drawStartTime) / 1000).toFixed(1);
                updateStatus(`⏹ Cancelled (${elapsedSec}s elapsed)`, '#ef4444');
            } else {
                const elapsedSec = ((performance.now() - drawStartTime) / 1000).toFixed(1);
                document.getElementById('db-progress-bar').style.width = '100%';
                document.getElementById('db-progress-percent').textContent = '100%';
                document.getElementById('db-progress-time').textContent = `Done in ${elapsedSec}s!`;
                if (drawMode === 'fill' && fastFillPacketsSent > 0 && (fastFillVisualCheckInconclusive || fastFillProbeSupported)) {
                    updateStatus(`✅ Tool 8 packets sent in ${elapsedSec}s. (When you click "Done" the art will temporarily disappear, but it is fully saved and will be visible at the end of the game)`, '#10b981');
                } else {
                    updateStatus(`✅ Drawing completed in ${elapsedSec}s! (When you click "Done" the art will temporarily disappear, but it is fully saved and will be visible at the end)`, '#10b981');
                }
            }
        } catch (err) {
            console.error('[DrawBot] Critical error during drawing:', err);
            updateStatus('❌ Error during drawing', '#ef4444');
        } finally {
            strokeIdCounter = strokeId;
            resetDrawUI();
        }
    }

    // --- UI Settings Persistence ---
    function saveSettings() {
        try {
            const settings = {
                scale: document.getElementById('db-scale')?.value || '4',
                colorsMode: document.getElementById('db-colors-mode')?.value || '8',
                drawMode: document.getElementById('db-draw-mode')?.value || 'fill',
                batchSize: document.getElementById('db-batch-size')?.value || '3000',
                delay: document.getElementById('db-delay')?.value || '127',
                fillPPS: document.getElementById('db-fill-pps')?.value || '8',
                denoise: document.getElementById('db-denoise-level')?.value || '0',
                fillBg: document.getElementById('db-fill-bg')?.checked ?? false,
                useBridge: document.getElementById('db-use-bridge')?.checked ?? true,
                bridgeLen: document.getElementById('db-bridge-len')?.value || '50',
                layoutMode: layoutMode,
                customX: customX,
                customY: customY,
                customW: customW,
                customH: customH
            };
            localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
        } catch (e) {
            console.warn('[DrawBot] Failed to save settings:', e);
        }
    }
    function loadSettings() {
        try {
            const data = localStorage.getItem(SETTINGS_KEY);
            if (!data) return null;
            return JSON.parse(data);
        } catch (e) {
            console.warn('[DrawBot] Failed to load settings:', e);
            return null;
        }
    }

    // --- Build UI ---
    function buildUI() {
        // Remove duplicates before check
        const existingPanels = document.querySelectorAll('#db-panel');
        if (existingPanels.length > 0) {
            for (let i = 1; i < existingPanels.length; i++) {
                console.log('[DrawBot] Bot panel duplicate removed');
                existingPanels[i].remove();
            }
            return;
        }
        if (document.getElementById('db-panel') || !document.head) return;

        // Inject Styles
        const style = document.createElement('style');
        style.innerHTML = `
            @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap');
            #db-panel {
                position: fixed; top: 20px; left: 20px; z-index: 2147483647;
                width: 280px; background: rgba(15, 23, 42, 0.9);
                backdrop-filter: blur(12px) saturate(180%);
                -webkit-backdrop-filter: blur(12px) saturate(180%);
                border: 1px solid rgba(255, 255, 255, 0.1);
                border-radius: 16px; font-family: 'Outfit', 'Inter', sans-serif;
                padding: 16px; color: #f1f5f9;
                box-shadow: 0 20px 25px -5px rgba(0,0,0,0.5), 0 10px 10px -5px rgba(0,0,0,0.4);
                transition: box-shadow 0.3s, border-color 0.3s;
                user-select: none;
                cursor: move;
            }
            #db-panel:hover {
                border-color: rgba(255, 255, 255, 0.18);
                box-shadow: 0 25px 30px -5px rgba(0,0,0,0.6), 0 15px 15px -5px rgba(0,0,0,0.5);
            }
            .pulse-dot {
                display: inline-block; width: 8px; height: 8px;
                background-color: #a78bfa; border-radius: 50%;
                box-shadow: 0 0 0 0 rgba(167, 139, 250, 0.7);
                animation: db-pulse 1.6s infinite cubic-bezier(0.66, 0, 0, 1);
            }
            @keyframes db-pulse {
                to { box-shadow: 0 0 0 8px rgba(167, 139, 250, 0); }
            }
        `;
        document.head.appendChild(style);

        const panel = document.createElement('div');
        panel.id = 'db-panel';
        panel.innerHTML = `
            <div id="db-header" style="cursor: move; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(255, 255, 255, 0.1); padding-bottom: 8px; margin-bottom: 12px;">
                <div style="font-weight: 800; color: #a78bfa; font-size: 14px; display: flex; align-items: center; gap: 6px;">
                    <span class="pulse-dot"></span>
                    DrawBot 1.1
                </div>
                <button id="db-minimize-btn" style="background: none; border: none; color: #94a3b8; cursor: pointer; font-size: 12px; transition: color 0.2s;">➖</button>
            </div>

            <div id="db-content">
                <div style="margin-bottom: 12px;">
                    <div style="display: flex; gap: 6px; margin-bottom: 6px;">
                        <input id="db-url" type="text" placeholder="Image URL" style="flex: 1; padding: 8px; background: #1e293b; border: 1px solid #334155; color: white; border-radius: 8px; font-size: 12px; outline: none; transition: border-color 0.2s;">
                        <button id="db-file-btn" style="background: #334155; border: 1px solid #475569; color: white; padding: 8px 12px; border-radius: 8px; font-size: 13px; cursor: pointer; transition: background 0.2s;">📁</button>
                    </div>
                    <input id="db-file" type="file" accept="image/*" style="display: none;">
                </div>

                <div id="db-preview-container" style="display: none; flex-direction: column; align-items: center; background: #0b0f19; border-radius: 10px; padding: 8px; margin-bottom: 12px; border: 1px solid #1e293b;">
                    <canvas id="db-preview-canvas" style="width: 100%; max-height: 140px; border-radius: 6px; object-fit: contain; image-rendering: pixelated; image-rendering: -moz-crisp-edges; image-rendering: crisp-edges;"></canvas>
                    <div id="db-preview-info" style="font-size: 10px; color: #94a3b8; margin-top: 6px; text-align: center; line-height: 1.4;"></div>
                </div>

                <div style="display: flex; gap: 8px; margin-bottom: 10px;">
                    <div style="flex: 1; font-size: 11px;">
                        <label style="display: block; margin-bottom: 4px; color: #94a3b8;">Scale:</label>
                        <select id="db-scale" style="width: 100%; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 6px; font-size: 11px; outline: none; cursor: pointer;">
                            <option value="1">1x (HD)</option>
                            <option value="2">2x (Detailed)</option>
                            <option value="3">3x (Recommended)</option>
                            <option value="4" selected>4x (Sketch)</option>
                            <option value="5">5x (Pixel Art)</option>
                            <option value="6">6x (Large)</option>
                            <option value="7">7x (Bold)</option>
                            <option value="8">8x (Mosaic)</option>
                            <option value="9">9x (Very Large)</option>
                            <option value="10">10x (Max Speed)</option>
                            <option value="11">11x</option>
                            <option value="12">12x</option>
                            <option value="13">13x</option>
                            <option value="14">14x</option>
                            <option value="15">15x</option>
                        </select>
                    </div>
                    <div style="flex: 1; font-size: 11px;">
                        <label style="display: block; margin-bottom: 4px; color: #94a3b8;">Colors:</label>
                        <select id="db-colors-mode" style="width: 100%; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 6px; font-size: 11px; outline: none; cursor: pointer;">
                                <option value="gartic">Game palette (18)</option>
                                <option value="8" selected>True Color (8)</option>
                            <option value="16">True Color (16)</option>
                            <option value="24">True Color (24)</option>
                            <option value="32">True Color (32)</option>
                            <option value="48">True Color (48)</option>
                            <option value="64">True Color (64)</option>
                            <option value="128">True Color (128)</option>
                            <option value="256">True Color (256)</option>
                            <option value="infinite">No limit</option>
                        </select>
                    </div>
                </div>

                <div style="display: flex; gap: 8px; margin-bottom: 10px;">
                    <div style="flex: 1; font-size: 11px;">
                        <label style="display: block; margin-bottom: 4px; color: #94a3b8;">Mode:</label>
                        <select id="db-draw-mode" style="width: 100%; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 6px; font-size: 11px; outline: none; cursor: pointer;">
                            <option value="fill" selected>Fast Fill</option>
                            <option value="lines">Standard Lines</option>
                        </select>
                    </div>
                    <div id="db-batch-wrapper" style="flex: 1; font-size: 11px;">
                        <label style="display: block; margin-bottom: 4px; color: #94a3b8;">Rects per packet:</label>
                        <input type="number" id="db-batch-size" min="1" max="1000" value="3000" style="width: 100%; box-sizing: border-box; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 6px; font-size: 11px; outline: none; text-align: center; font-weight: 600;">
                    </div>
                </div>

                <div style="display: flex; gap: 8px; margin-bottom: 10px;">
                    <div style="flex: 1; font-size: 11px;">
                        <label style="display: block; margin-bottom: 4px; color: #94a3b8;">Layout:</label>
                        <select id="db-layout-mode" style="width: 100%; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 6px; font-size: 11px; outline: none; cursor: pointer;">
                            <option value="stretch" selected>Stretch</option>
                            <option value="center">Center</option>
                            <option value="custom">Custom Scale</option>
                        </select>
                    </div>
                    <div style="flex: 1; font-size: 11px;">
                    </div>
                </div>

                <div id="db-custom-sliders" style="display: none; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; padding: 8px; margin-bottom: 10px;">
                    <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 10px;">
                        <span style="color: #94a3b8;">Width:</span>
                        <input type="range" id="db-custom-w" min="10" max="768" value="384" style="width: 120px; accent-color: #a78bfa;">
                        <span id="db-val-w" style="color: #a78bfa; font-weight: bold; width: 30px; text-align: right;">384</span>
                    </div>
                    <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 10px;">
                        <span style="color: #94a3b8;">Height:</span>
                        <input type="range" id="db-custom-h" min="10" max="448" value="224" style="width: 120px; accent-color: #a78bfa;">
                        <span id="db-val-h" style="color: #a78bfa; font-weight: bold; width: 30px; text-align: right;">224</span>
                    </div>
                    <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 10px;">
                        <span style="color: #94a3b8;">X Pos:</span>
                        <input type="range" id="db-custom-x" min="0" max="768" value="0" style="width: 120px; accent-color: #a78bfa;">
                        <span id="db-val-x" style="color: #a78bfa; font-weight: bold; width: 30px; text-align: right;">0</span>
                    </div>
                    <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 10px;">
                        <span style="color: #94a3b8;">Y Pos:</span>
                        <input type="range" id="db-custom-y" min="0" max="448" value="0" style="width: 120px; accent-color: #a78bfa;">
                        <span id="db-val-y" style="color: #a78bfa; font-weight: bold; width: 30px; text-align: right;">0</span>
                    </div>
                    <button id="db-custom-apply" style="width: 100%; padding: 6px; background: #8b5cf6; border: none; color: white; font-weight: bold; border-radius: 6px; cursor: pointer; font-size: 11px; transition: background 0.2s;">Apply & Render Preview</button>
                </div>

                <div id="db-delay-wrapper" style="margin-bottom: 10px; font-size: 11px; display: flex; align-items: center; justify-content: space-between;">
                    <span style="color: #94a3b8;">Packet delay (ms):</span>
                    <input type="number" id="db-delay" min="0" max="1000" value="127" style="width: 70px; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 8px; font-size: 11px; outline: none; text-align: center; font-weight: 600;">
                </div>

                <div id="db-fill-pps-wrapper" style="margin-bottom: 10px; font-size: 11px; display: flex; align-items: center; justify-content: space-between; background: rgba(167, 139, 250, 0.06); border: 1px solid rgba(167, 139, 250, 0.15); border-radius: 8px; padding: 8px;">
                    <span style="color: #c4b5fd; font-weight: 600;">⚡ Packets/s (fill):</span>
                    <input type="number" id="db-fill-pps" min="1" max="30" value="8" style="width: 60px; padding: 6px; background: #1e293b; color: #c4b5fd; border: 1px solid #7c3aed; border-radius: 8px; font-size: 11px; outline: none; text-align: center; font-weight: 800;">
                </div>

                <div id="db-bridge-len-wrapper" style="margin-bottom: 10px; font-size: 11px; display: flex; align-items: center; justify-content: space-between; background: rgba(167, 139, 250, 0.06); border: 1px solid rgba(167, 139, 250, 0.15); border-radius: 8px; padding: 8px;">
                    <span style="color: #c4b5fd; font-weight: 600;">🌉 Bridge length (px):</span>
                    <input type="number" id="db-bridge-len" min="1" max="150" value="50" style="width: 60px; padding: 6px; background: #1e293b; color: #c4b5fd; border: 1px solid #7c3aed; border-radius: 8px; font-size: 11px; outline: none; text-align: center; font-weight: 800;">
                </div>

                <div style="display: flex; gap: 8px; margin-bottom: 12px;">
                    <div style="flex: 1; font-size: 11px;">
                        <label style="display: block; margin-bottom: 4px; color: #94a3b8;">Denoise filter:</label>
                        <select id="db-denoise-level" style="width: 100%; padding: 6px; background: #1e293b; color: white; border: 1px solid #334155; border-radius: 6px; font-size: 11px; outline: none; cursor: pointer;">
                            <option value="0" selected>Off</option>
                            <option value="1">Weak (1px)</option>
                            <option value="2">Medium (2px)</option>
                            <option value="3">Strong (3px)</option>
                        </select>
                    </div>
                    <div style="flex: 1; font-size: 11px; display: flex; flex-direction: column; justify-content: flex-end; gap: 6px; padding-bottom: 2px;">
                        <label style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #94a3b8;">
                            <input type="checkbox" id="db-fill-bg" style="accent-color: #a78bfa;"> Fill background
                        </label>
                        <label id="db-bridge-wrapper" style="display: flex; align-items: center; gap: 6px; cursor: pointer; color: #94a3b8;">
                            <input type="checkbox" id="db-use-bridge" checked style="accent-color: #a78bfa;"> Smart Bridges
                        </label>
                    </div>
                </div>

                <div id="db-status-container" style="background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.05); border-radius: 8px; padding: 8px; margin-bottom: 12px; font-size: 11px; line-height: 1.4;">
                    <div id="db-status" style="font-weight: 600; color: #94a3b8;">Waiting for image...</div>
                    <div id="db-progress-wrapper" style="display: none; margin-top: 8px;">
                        <div style="background: rgba(255,255,255,0.08); height: 6px; border-radius: 3px; overflow: hidden; margin-bottom: 4px; border: 1px solid rgba(255,255,255,0.05);">
                            <div id="db-progress-bar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #a78bfa, #818cf8); transition: width 0.1s;"></div>
                        </div>
                        <div style="display: flex; justify-content: space-between; font-size: 9px; color: #94a3b8; font-weight: 500;">
                            <span id="db-progress-percent">0%</span>
                            <span id="db-progress-time">Remaining: ~0s</span>
                        </div>
                    </div>
                </div>

                <div id="db-log-container" style="background: rgba(239, 68, 68, 0.08); border: 1px solid rgba(239, 68, 68, 0.2); border-radius: 8px; padding: 8px; margin-bottom: 12px; font-size: 10px; line-height: 1.4; display: none; max-height: 80px; overflow-y: auto;">
                    <div style="font-weight: bold; color: #ef4444; margin-bottom: 4px;">Error log:</div>
                    <div id="db-log-content" style="color: #fca5a5; font-family: monospace; white-space: pre-wrap; font-size: 9px;"></div>
                </div>

                <div style="display: flex; flex-direction: column; gap: 8px;">
                    <button id="db-start" style="width: 100%; padding: 10px; background: #4f46e5; border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer; transition: background 0.2s, transform 0.1s; font-size: 12px; box-shadow: 0 4px 6px -1px rgba(79, 70, 229, 0.3);">▶ Start Drawing</button>
                    <div style="display: flex; gap: 8px;">
                        <button id="db-pause" style="flex: 1; padding: 8px; background: #d97706; border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer; transition: background 0.2s; font-size: 11px; display: none;">⏸ Pause</button>
                        <button id="db-cancel" style="flex: 1; padding: 8px; background: #b91c1c; border: none; color: white; font-weight: 600; border-radius: 8px; cursor: pointer; transition: background 0.2s; font-size: 11px; display: none;">⏹ Cancel</button>
                    </div>
                </div>
            </div>
        `;

        document.body.appendChild(panel);

        // Load saved settings
        const savedSetting = loadSettings();
        if (savedSetting) {
            if (document.getElementById('db-scale')) document.getElementById('db-scale').value = savedSetting.scale;
            if (document.getElementById('db-colors-mode')) document.getElementById('db-colors-mode').value = savedSetting.colorsMode;
            if (document.getElementById('db-draw-mode')) document.getElementById('db-draw-mode').value = savedSetting.drawMode;
            if (document.getElementById('db-batch-size')) document.getElementById('db-batch-size').value = savedSetting.batchSize;
            if (document.getElementById('db-delay')) document.getElementById('db-delay').value = savedSetting.delay;
            if (document.getElementById('db-fill-pps')) document.getElementById('db-fill-pps').value = savedSetting.fillPPS;
            if (document.getElementById('db-denoise-level')) document.getElementById('db-denoise-level').value = savedSetting.denoise;
            if (document.getElementById('db-fill-bg')) document.getElementById('db-fill-bg').checked = savedSetting.fillBg;
            if (document.getElementById('db-use-bridge')) document.getElementById('db-use-bridge').checked = savedSetting.useBridge;
            if (document.getElementById('db-bridge-len')) document.getElementById('db-bridge-len').value = savedSetting.bridgeLen;
            if (savedSetting.layoutMode) layoutMode = savedSetting.layoutMode;
            if (savedSetting.customX !== undefined) customX = savedSetting.customX;
            if (savedSetting.customY !== undefined) customY = savedSetting.customY;
            if (savedSetting.customW !== undefined) customW = savedSetting.customW;
            if (savedSetting.customH !== undefined) customH = savedSetting.customH;
        }

        if (document.getElementById('db-layout-mode')) {
            document.getElementById('db-layout-mode').value = layoutMode;
            if (layoutMode === 'custom') {
                document.getElementById('db-custom-sliders').style.display = 'block';
            }
        }

        // Make draggable
        makeDraggable(panel, panel);

        // Event Listeners
        const minimizeBtn = document.getElementById('db-minimize-btn');
        const contentDiv = document.getElementById('db-content');
        let isMinimized = false;
        minimizeBtn.addEventListener('click', () => {
            isMinimized = !isMinimized;
            if (isMinimized) {
                contentDiv.style.display = 'none';
                minimizeBtn.textContent = '➕';
                panel.style.width = '180px';
            } else {
                contentDiv.style.display = 'block';
                minimizeBtn.textContent = '➖';
                panel.style.width = '280px';
            }
        });

        // File loading
        const fileBtn = document.getElementById('db-file-btn');
        const fileInput = document.getElementById('db-file');
        fileBtn.addEventListener('click', () => fileInput.click());

        fileInput.addEventListener('change', (e) => {
            const file = e.target.files[0];
            if (!file) return;
            console.log('[DrawBot] File selected:', file.name);
            const reader = new FileReader();
            reader.onload = (event) => {
                currentImageSrc = event.target.result;
                console.log('[DrawBot] File loaded into memory, length:', currentImageSrc.length);
                document.getElementById('db-url').value = file.name;
                
                // Preload draftImg and update layout bounds
                loadImage(currentImageSrc).then(img => {
                    draftImg = img;
                    const imgRatio = img.width / img.height;
                    const canvasRatio = 768 / 448;
                    if (imgRatio > canvasRatio) {
                        customW = 400;
                        customH = Math.round(400 / imgRatio);
                    } else {
                        customH = 250;
                        customW = Math.round(250 * imgRatio);
                    }
                    customX = Math.round((768 - customW) / 2);
                    customY = Math.round((448 - customH) / 2);
                    clampCustomBounds();
                    updateSliders();
                    
                    if (layoutMode === 'custom') {
                        drawDraftPreview();
                        isApplied = false;
                    } else {
                        processAndPreviewImage(currentImageSrc);
                        isApplied = true;
                    }
                }).catch(err => {
                    console.error('[DrawBot] Error preloading draft image:', err);
                    processAndPreviewImage(currentImageSrc);
                    isApplied = true;
                });
                updateStatus('File loaded. Click Start.', '#a78bfa');
            };
            reader.readAsDataURL(file);
        });

        // URL loading
        const urlInput = document.getElementById('db-url');
        urlInput.addEventListener('input', (e) => {
            const val = e.target.value.trim();
            if (val && (val.startsWith('http') || val.startsWith('data:'))) {
                currentImageSrc = val;
                loadImage(currentImageSrc).then(img => {
                    draftImg = img;
                    const imgRatio = img.width / img.height;
                    const canvasRatio = 768 / 448;
                    if (imgRatio > canvasRatio) {
                        customW = 400;
                        customH = Math.round(400 / imgRatio);
                    } else {
                        customH = 250;
                        customW = Math.round(250 * imgRatio);
                    }
                    customX = Math.round((768 - customW) / 2);
                    customY = Math.round((448 - customH) / 2);
                    clampCustomBounds();
                    updateSliders();
                    
                    if (layoutMode === 'custom') {
                        drawDraftPreview();
                        isApplied = false;
                    } else {
                        processAndPreviewImage(currentImageSrc);
                        isApplied = true;
                    }
                }).catch(err => {
                    console.error('[DrawBot] Error preloading draft image from URL:', err);
                    processAndPreviewImage(currentImageSrc);
                    isApplied = true;
                });
                updateStatus('Image loaded from URL.', '#a78bfa');
            }
        });

        // Preview updating triggers
        const handleTriggerUpdate = () => {
            saveSettings();
            if (layoutMode === 'custom') {
                drawDraftPreview();
                isApplied = false;
            } else {
                processAndPreviewImage(currentImageSrc);
                isApplied = true;
            }
        };

        document.getElementById('db-scale').addEventListener('change', () => {
            handleTriggerUpdate();
        });
        document.getElementById('db-colors-mode').addEventListener('change', () => {
            handleTriggerUpdate();
        });
        document.getElementById('db-denoise-level').addEventListener('change', () => {
            handleTriggerUpdate();
        });
        document.getElementById('db-fill-bg').addEventListener('change', () => {
            handleTriggerUpdate();
        });
        document.getElementById('db-use-bridge').addEventListener('change', () => {
            handleTriggerUpdate();
        });
 
         const drawModeSelect = document.getElementById('db-draw-mode');
         const batchWrapper = document.getElementById('db-batch-wrapper');
         drawModeSelect.addEventListener('change', () => {
             handleTriggerUpdate();
         });
 
         document.getElementById('db-batch-size').addEventListener('input', () => {
             let val = parseInt(document.getElementById('db-batch-size').value, 10);
             if (isNaN(val) || val < 1) val = 1;
             handleTriggerUpdate();
         });
 
         const delayInput = document.getElementById('db-delay');
         delayInput.addEventListener('input', () => {
             let delay = parseInt(delayInput.value, 10);
             if (isNaN(delay) || delay < 0) delay = 0;
             CFG.packetDelay = delay;
             handleTriggerUpdate();
         });
 
         const fillPPSInput = document.getElementById('db-fill-pps');
         fillPPSInput.addEventListener('input', () => {
             let val = parseInt(fillPPSInput.value, 10);
             if (isNaN(val) || val < 1) val = 1;
             if (val > 30) val = 30;
             CFG.fillPPS = val;
             handleTriggerUpdate();
         });
 
         const bridgeLenInput = document.getElementById('db-bridge-len');
         if (bridgeLenInput) {
             bridgeLenInput.addEventListener('input', () => {
                 let val = parseInt(bridgeLenInput.value, 10);
                 if (isNaN(val) || val < 1) val = 1;
                 if (val > 150) val = 150;
                 CFG.maxBridgeLength = val;
                 handleTriggerUpdate();
             });
         }

        // Show/hide PPS and Delay depending on drawing mode
        const fillPPSWrapper = document.getElementById('db-fill-pps-wrapper');
        const delayWrapper = document.getElementById('db-delay-wrapper');
        const bridgeWrapper = document.getElementById('db-bridge-wrapper');
        const bridgeLenWrapper = document.getElementById('db-bridge-len-wrapper');
        const updateFillPPSVisibility = () => {
            const isFill = drawModeSelect.value === 'fill';
            const useBridge = document.getElementById('db-use-bridge') ? document.getElementById('db-use-bridge').checked : false;
            if (fillPPSWrapper) {
                fillPPSWrapper.style.display = isFill ? 'flex' : 'none';
            }
            if (delayWrapper) {
                delayWrapper.style.display = isFill ? 'none' : 'flex';
            }
            if (bridgeWrapper) {
                bridgeWrapper.style.display = isFill ? 'flex' : 'none';
            }
            if (bridgeLenWrapper) {
                bridgeLenWrapper.style.display = (isFill && useBridge) ? 'flex' : 'none';
            }
            if (batchWrapper) {
                batchWrapper.style.display = isFill ? 'block' : 'none';
            }
        };
        drawModeSelect.addEventListener('change', updateFillPPSVisibility);
        if (document.getElementById('db-use-bridge')) {
            document.getElementById('db-use-bridge').addEventListener('change', updateFillPPSVisibility);
        }
        updateFillPPSVisibility();

        // Layout change handler
        const layoutSelect = document.getElementById('db-layout-mode');
        const customSliders = document.getElementById('db-custom-sliders');
        
        if (layoutSelect) {
            layoutSelect.addEventListener('change', () => {
                layoutMode = layoutSelect.value;
                saveSettings();
                if (layoutMode === 'custom') {
                    if (customSliders) customSliders.style.display = 'block';
                    updateSliders();
                    drawDraftPreview();
                    isApplied = false;
                } else {
                    if (customSliders) customSliders.style.display = 'none';
                    processAndPreviewImage(currentImageSrc);
                    isApplied = true;
                }
            });
        }

        // Sliders input handlers
        const wSlider = document.getElementById('db-custom-w');
        const hSlider = document.getElementById('db-custom-h');
        const xSlider = document.getElementById('db-custom-x');
        const ySlider = document.getElementById('db-custom-y');
        
        const onSliderInput = () => {
            if (wSlider) customW = parseInt(wSlider.value, 10);
            if (hSlider) customH = parseInt(hSlider.value, 10);
            if (xSlider) customX = parseInt(xSlider.value, 10);
            if (ySlider) customY = parseInt(ySlider.value, 10);
            
            clampCustomBounds();
            updateSliders();
            drawDraftPreview();
            isApplied = false;
        };
        
        if (wSlider) wSlider.addEventListener('input', onSliderInput);
        if (hSlider) hSlider.addEventListener('input', onSliderInput);
        if (xSlider) xSlider.addEventListener('input', onSliderInput);
        if (ySlider) ySlider.addEventListener('input', onSliderInput);
        
        const customApplyBtn = document.getElementById('db-custom-apply');
        if (customApplyBtn) {
            customApplyBtn.addEventListener('click', () => {
                processAndPreviewImage(currentImageSrc);
                isApplied = true;
            });
        }

        // Canvas dragging listener
        const pCanvas = document.getElementById('db-preview-canvas');
        if (pCanvas) {
            pCanvas.addEventListener('mousedown', handleCanvasMouseDown);
            pCanvas.addEventListener('mousemove', handleCanvasMouseMoveNoDrag);
        }

        // Drawing control triggers
        document.getElementById('db-start').addEventListener('click', () => {
            if (!currentImageSrc) {
                alert('Please load an image first (file or URL)!');
                return;
            }
            runDraw();
        });



        const pauseBtn = document.getElementById('db-pause');
        pauseBtn.addEventListener('click', () => {
            console.log('[DrawBot] Pause button clicked. State changes to:', !isPaused);
            isPaused = !isPaused;
            if (isPaused) {
                pauseBtn.textContent = '▶ Resume';
                pauseBtn.style.background = '#059669';
                updateStatus('⏸ Pause', '#fbbf24');
            } else {
                pauseBtn.textContent = '⏸ Pause';
                pauseBtn.style.background = '#d97706';
                updateStatus('Drawing...', '#a78bfa');
            }
        });

        document.getElementById('db-cancel').addEventListener('click', () => {
            console.log('[DrawBot] Cancel button clicked. cancelFlag set to true.');
            cancelFlag = true;
            isPaused = false;
        });

        // Hook initial websocket check
        if (wsInstance) {
            updateStatus('Socket ready. Select a file/URL and click Start.', '#10b981');
        } else {
            updateStatus('Waiting for game connection...', '#94a3b8');
        }
    }

    function makeDraggable(el, header) {
        let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
        header.onmousedown = dragMouseDown;

        function dragMouseDown(e) {
            e = e || window.event;
            if (e.target.closest('button, input, select, canvas, label, a')) return;
            e.preventDefault();
            pos3 = e.clientX;
            pos4 = e.clientY;
            document.onmouseup = closeDragElement;
            document.onmousemove = elementDrag;
        }

        function elementDrag(e) {
            e = e || window.event;
            e.preventDefault();
            pos1 = pos3 - e.clientX;
            pos2 = pos4 - e.clientY;
            pos3 = e.clientX;
            pos4 = e.clientY;
            el.style.top = (el.offsetTop - pos2) + "px";
            el.style.left = (el.offsetLeft - pos1) + "px";
        }

        function closeDragElement() {
            document.onmouseup = null;
            document.onmousemove = null;
        }
    }

    // Initialize UI
    function init() {
        buildUI();
    }
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();