Wplace chunk downloader

Easily download chunk images from wplace.live using multi-point selection and highlighting

Från och med 2025-08-19. Se den senaste versionen.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Wplace chunk downloader
// @namespace    http://tampermonkey.net/
// @version      1.0.2
// @description  Easily download chunk images from wplace.live using multi-point selection and highlighting
// @author       NotNotWaldo
// @match        https://wplace.live/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=wplace.live
// @license      MIT
// @run-at       document-end
// @grant        none
// ==/UserScript==



// Code below, feel free to read in horror

// Global vals because I'm way too lazy

// These variables are for handling the highlighting feature
let isHightlightOn = false;
let downloadingState = false;
// the downloading state ensures that the highlight wont be also printed when downloading the images of chunk

// just a template for chunk img
const chunkTemplateUrl = `https://backend.wplace.live/files/s0/tiles/`;

(() => {
    // array for highlighting chunks
  let highlightedChunksLinksArr = [];

    // the coords of chunks that are selected by the points you've set 
  let mlChunkCoords = {
    firstChunk: { x: null, y: null },
    secondChunk: { x: null, y: null },
  };

  const originalFetch = window.fetch;

  const mlCoordsOrganizer = async (mlCoords) => {
    let tempMlChunkCoords = structuredClone(mlChunkCoords);

    // checks if the second point is empty. If yes, it copies the val of first point onto the second
    if (mlCoords.secondChunk.x == null) {
      tempMlChunkCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
      tempMlChunkCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
      return tempMlChunkCoords;
    }

    // making sure that the coords that will be sent would be appropriate
    // turns the first point to be the topleft corner and the second the bottom right
    tempMlChunkCoords.firstChunk.x = Math.min(
      mlCoords.firstChunk.x,
      mlCoords.secondChunk.x
    );
    tempMlChunkCoords.secondChunk.x = Math.max(
      mlCoords.firstChunk.x,
      mlCoords.secondChunk.x
    );
    tempMlChunkCoords.firstChunk.y = Math.min(
      mlCoords.firstChunk.y,
      mlCoords.secondChunk.y
    );
    tempMlChunkCoords.secondChunk.y = Math.max(
      mlCoords.firstChunk.y,
      mlCoords.secondChunk.y
    );

    return tempMlChunkCoords;
  };

  window.fetch = async (resource, init) => {
    
    const url = new URL(
      typeof resource === "string" ? resource : resource.url || ""
    );
    const res = await originalFetch(resource, init);

    const x = url.searchParams.get("x");
    const y = url.searchParams.get("y");

    // This part is for the wonky feature that highlights a chunk, the reason it is wonky is becuase
    // it has to wait for the app to reload/refetch the img so we can apply effects
    // Why wait? because the images are flattened in the <canvas> object, and I have no access
    // (or I dont know how to get access) to the functions that sets the <canvas> obj
    // leaving me unable to modify or even force to reload/fetch the image
    
    // Detect tile fetches
    if (url.pathname.endsWith(".png")) {
      const pathParts = url.pathname.split("/");
      const chunkX = pathParts[pathParts.length - 2];
      const chunkY = pathParts[pathParts.length - 1].replace(".png", "");

      // If this chunk matches our selected one
      if (
        isHightlightOn &&
        highlightedChunksLinksArr.includes(
          chunkTemplateUrl + chunkX + "/" + chunkY + ".png"
        ) &&
        !downloadingState
      ) {

        // Clone so we don't consume the original stream
        const cloned = res.clone();
        const blob = await cloned.blob();
        const imgBitmap = await createImageBitmap(blob);

        // Draw onto an offscreen canvas
        const canvas = document.createElement("canvas");
        canvas.width = imgBitmap.width;
        canvas.height = imgBitmap.height;
        const ctx = canvas.getContext("2d");

        // Draw original
        ctx.drawImage(imgBitmap, 0, 0);

        // Add blue overlay
        ctx.fillStyle = "rgba(0, 0, 255, 0.2)";
        ctx.fillRect(0, 0, canvas.width, canvas.height);

        const modifiedBlob = await new Promise((r) =>
          canvas.toBlob(r, "image/png")
        );

        return new Response(modifiedBlob, {
          status: res.status,
          statusText: res.statusText,
          headers: res.headers,
        });
      }
    }

    if (x && y) {
      // Setting important infos
      const pathnames = url.pathname.split("/");
      const chunkX = pathnames[pathnames.length - 2];
      const chunkY = pathnames[pathnames.length - 1];
      const chunkUrl = `https://backend.wplace.live/files/s0/tiles/${chunkX}/${chunkY}.png`;

      const parent = document
        .querySelector(".rounded-t-box")
        ?.querySelector("div");

      if (parent) {
        const coordsParent = parent.querySelector("h2.mt-0\\.5");
        const insertAfterThisObj = coordsParent.querySelector(
          "span.whitespace-nowrap:nth-child(1)"
        );
        const pixelBtns = parent.querySelector(".hide-scrollbar");
        let chunkCoords = parent.querySelector(".chunkCoords");

        if (!chunkCoords) {
          chunkCoords = document.createElement("span");
          chunkCoords.className = "chunkCoords whitespace-nowrap";
          
          insertAfterThisObj.after(chunkCoords);
        }

        chunkCoords.textContent = ` | Chunk: ${chunkX}, ${chunkY}`;


        // checks if the buttons are already there
        if (pixelBtns.children.length > 3) {
          // remove those buttons <<< WILL BREAK IF THE DEVS ADD NEW BUTTONS TO IT LOL, it has, multiple times, because of me lmao.
          for (let i = 0; i < 4 && pixelBtns.lastElementChild; i++) {
            pixelBtns.removeChild(pixelBtns.lastElementChild);
          }
        }

        if (pixelBtns) {
          pixelBtns.style.flexWrap = "wrap";
          pixelBtns.style.overflowX = "visible";

          // adds "View chunk img" button
          const viewImgBtn = document.createElement("button");
          viewImgBtn.className = "btn btn-soft";
          viewImgBtn.textContent = "View chunk img";
          viewImgBtn.addEventListener("click", () => {
            window.open(chunkUrl, "_blank");
          });

          // adds "Download chunk png" button
          const dlBtn = document.createElement("button");
          dlBtn.className = "btn btn-soft";
          dlBtn.textContent = "Download chunk png";
          dlBtn.addEventListener("click", async () => {
            multipleChunksDlUrl(chunkX, chunkY, chunkX, chunkY);
          });

          const downloadChunksText = document.createElement("span");
          downloadChunksText.className = "w-full pt-2";
          downloadChunksText.textContent = "Multiple chunks downloader:";

          const refreshSetPointsInfo = () => {
            let infoChildren = mlChunkDlCon.querySelectorAll("div");
            infoChildren[0].textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;
            infoChildren[1].textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;
          };

          // This are for the multiple chunks downloader
          let mlChunkDlCon = document.createElement("div");
          mlChunkDlCon.className = "flex sm:rounded-b-box w-full pt- sm:mb-2";
          mlChunkDlCon.style.gap = "5px";
          mlChunkDlCon.style.flexWrap = "wrap";
          mlChunkDlCon.style.overflowX = "visible";

          // Yes, these "divs" has their classes set to "btn"... a class for <button> objs... I just dont wanna do css
          let topLeftCoords = document.createElement("div");
          topLeftCoords.className = "btn btn-soft";
          topLeftCoords.textContent = `1st X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}`;

          let botRightCoords = document.createElement("div");
          botRightCoords.className = "btn btn-soft";
          botRightCoords.textContent = `2nd X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}`;

          let setPointBtn = document.createElement("button");
          setPointBtn.textContent = "Set point";
          setPointBtn.className = "btn btn-soft btn-primary";

          mlChunkDlCon.appendChild(setPointBtn);
          mlChunkDlCon.appendChild(topLeftCoords);
          mlChunkDlCon.appendChild(botRightCoords);

          setPointBtn.addEventListener("click", async () => {
            if (mlChunkCoords.firstChunk.x == null) {
              // yes I'm lazy
              mlChunkCoords.firstChunk = { x: chunkX, y: chunkY };
            } else if (mlChunkCoords.secondChunk.x == null) {
              mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
            } else {
              mlChunkCoords.firstChunk = mlChunkCoords.secondChunk;
              mlChunkCoords.secondChunk = { x: chunkX, y: chunkY };
            }

            if (isHightlightOn) {
              highlightedChunksLinksArr.length = 0;
              let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
              highlightedChunksLinksArr.push(
                ...getLinksFromChunkCoords(organizedCoords)
              );
            }

            refreshSetPointsInfo();

            console.log(
              `current mlcoords-> firstChunk: {X: ${mlChunkCoords.firstChunk.x}, Y: ${mlChunkCoords.firstChunk.y}}, secondChunk: {X: ${mlChunkCoords.secondChunk.x}, Y: ${mlChunkCoords.secondChunk.y}}`
            );
          });

            // creates the "download chunks" button
          let downloadChunksBtn = document.createElement("button");
          downloadChunksBtn.className = "btn btn-soft btn-primary";
          downloadChunksBtn.textContent = "Download chunks";
          downloadChunksBtn.addEventListener("click", async () => {
            let tempCoords = structuredClone(mlChunkCoords);
            if (
              mlChunkCoords.firstChunk.x == null &&
              mlChunkCoords.secondChunk.x == null
            )
                {return;}
            if (mlChunkCoords.secondChunk.x == null) {
              tempCoords.secondChunk.x = mlChunkCoords.firstChunk.x;
              tempCoords.secondChunk.y = mlChunkCoords.firstChunk.y;
            }
            console.log(
              `downloading chunks: ${tempCoords.firstChunk.x}, ${tempCoords.firstChunk.y} | ${tempCoords.secondChunk.x}, ${tempCoords.secondChunk.y}`
            );

            // making sure the coords are appropriate before passing it
            let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
            multipleChunksDlUrl(
              organizedCoords.firstChunk.x,
              organizedCoords.firstChunk.y,
              organizedCoords.secondChunk.x,
              organizedCoords.secondChunk.y
            );
          });

          let removePointsBtn = document.createElement("button");
          removePointsBtn.className = "btn btn-soft";
          removePointsBtn.textContent = "Remove points";
          removePointsBtn.addEventListener("click", async () => {
            mlChunkCoords = {
              firstChunk: { x: null, y: null },
              secondChunk: { x: null, y: null },
            };
            highlightedChunksLinksArr.length = 0;
            isHightlightOn = false;
            refreshSetPointsInfo();
            let highlightBtn = mlChunkDlCon.querySelector(".highlight-btn"); // I pray that this does not break :)
            highlightBtn.textContent = "Highlight chunks";
          });

          let highlightChunksBtn = document.createElement("button");
          highlightChunksBtn.className = "highlight-btn btn btn-soft";
          if (!isHightlightOn) {
            highlightChunksBtn.textContent = "Highlight chunks";
          } else {
            highlightChunksBtn.textContent = "Unhighlight chunks";
          }
          highlightChunksBtn.addEventListener("click", async () => {
            console.log("Trying to hightlight chunks");
            if (mlChunkCoords.firstChunk.x == null) return;
            if (!isHightlightOn) {
              let organizedCoords = await mlCoordsOrganizer(mlChunkCoords);
              console.log(Object.keys(organizedCoords));
              highlightedChunksLinksArr.push(
                ...getLinksFromChunkCoords(organizedCoords)
              );
              console.log(`Turned on hightlight`);
              isHightlightOn = !isHightlightOn;
              highlightChunksBtn.textContent = "Unhighlight chunks";
            } else {
              highlightedChunksLinksArr.length = 0;
              console.log(`Turned off highlight`);
              isHightlightOn = !isHightlightOn;
              highlightChunksBtn.textContent = "Highlight chunks";
            }
          });

          mlChunkDlCon.appendChild(setPointBtn);
          mlChunkDlCon.appendChild(topLeftCoords);
          mlChunkDlCon.appendChild(botRightCoords);
          mlChunkDlCon.appendChild(downloadChunksBtn);
          mlChunkDlCon.appendChild(removePointsBtn);
          mlChunkDlCon.appendChild(highlightChunksBtn);
          // phew, that was a lot...

          pixelBtns.appendChild(viewImgBtn);
          pixelBtns.appendChild(dlBtn);
          pixelBtns.appendChild(downloadChunksText);
          pixelBtns.appendChild(mlChunkDlCon);
        } else {
          console.error("Btn obj not found");
        }
      } else {
        console.error("Parent element not found");
      }
    }
    return res;
  };
})();

const multipleChunksDlUrl = async (
  topleftX,
  topleftY,
  botRightX,
  botRightY,
  safety = true
) => {
  let linksResultArr = getLinksFromChunkCoords({
    firstChunk: { x: topleftX, y: topleftY },
    secondChunk: { x: botRightX, y: botRightY },
  });
  downloadingState = true;
  let safetyThreshold = 70;

  let chunkWidth = 1 + Number(botRightX - topleftX);

  if (linksResultArr.length > safetyThreshold) {
    if (safety) {
      console.warn(
        `You were about to download ${linksResultArr.length} images but was prevented by this precaution. If you intentionally wanted to download that much, you can edit the script and change the "safetyThreshold" variable's value. Good luck.`
      );
      return;
    } else {
      console.log("Better pray to God...");
    }
  }
  stitchAndDownload(
    linksResultArr,
    chunkWidth,
    `ch(${topleftX}, ${topleftY}, ${botRightX}, ${botRightY})` // the name of the stitched file png
  );
};

let getLinksFromChunkCoords = (chunkCoords) => {
  console.log("getting the links from chunk coords.");
  console.log(
    "tempChunkCoords: " +
      `First chunk {x: ${chunkCoords.firstChunk.x}, y: ${chunkCoords.firstChunk.y}}, Second chunk {x: ${chunkCoords.secondChunk.x}, y: ${chunkCoords.secondChunk.y}}`
  );
  let topleftX = chunkCoords.firstChunk.x,
    topleftY = chunkCoords.firstChunk.y,
    botRightX = chunkCoords.secondChunk.x,
    botRightY = chunkCoords.secondChunk.y;

  if (botRightX == null) {
    botRightX = topleftX;
    botRightY = topleftY;
  }

  let chunkWidth = 1 + Number(botRightX - topleftX);
  let chunkHeight = 1 + Number(botRightY - topleftY);

  console.log("chunkWidth: " + chunkWidth);
  console.log("chunkHeight: " + chunkHeight);

  let linksArr = [];
  for (let j = 0; j < chunkHeight; j++) {
    for (let i = 0; i < chunkWidth; i++) {
      // I F*CKING HATE JAVASCRIPT. TF YOU MEAN THAT YOU THINK A F*CKING NUMBER IS A STRING!
      linksArr.push(
        chunkTemplateUrl +
          (Number(i) + Number(topleftX)) +
          "/" +
          (Number(j) + Number(topleftY)) +
          ".png"
      );
    }
  }
  return linksArr;
};

async function stitchAndDownload(images, width, name) {
  // Creates a temp 1000x1000 image

  const createBlank = () => {
    const c = document.createElement("canvas");
    c.width = 1000;
    c.height = 1000;
    const ctx = c.getContext("2d");
    ctx.fillStyle = "rgba(0,0,0,0)"; // or transparent
    ctx.fillRect(0, 0, c.width, c.height);
    return new Promise((resolve) => {
      c.toBlob((blob) => {
        const img = new Image();
        img.src = URL.createObjectURL(blob);
        img.onload = () => resolve(img);
      });
    });
  };

  // loads all images with delay
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

  async function loadImagesWithDelay(images, delay = 200) {
    const loadedImages = [];

    for (const src of images) {
      const img = await new Promise((resolve) => {
        const image = new Image();
        image.crossOrigin = "anonymous";
        image.onload = () => resolve(image);
        image.onerror = async () => {
          console.warn("Failed to load, using blank:", src);
          const blank = await createBlank();
          resolve(blank);
        };
        image.src = src;
      });

      loadedImages.push(img);

        // cooldown for lesser chance of being rate-limited by the website
      // wait before starting the next one
      await sleep(delay);
    }

    return loadedImages;
  }

  // usage
  const loadedImages = await loadImagesWithDelay(images, 200);

  // # of columns/rows
  const columns = width;
  const rows = Math.ceil(loadedImages.length / columns);

  const imgWidth = loadedImages[0].width;
  const imgHeight = loadedImages[0].height;

  const canvas = document.createElement("canvas");
  canvas.width = imgWidth * columns;
  canvas.height = imgHeight * rows;
  const ctx = canvas.getContext("2d");

  // this draw images in order: left to right, top to bottom
  loadedImages.forEach((img, index) => {
    const x = (index % columns) * imgWidth;
    const y = Math.floor(index / columns) * imgHeight;
    ctx.drawImage(img, x, y);
  });

  // Download stitched image
  canvas.toBlob((blob) => {
    const link = document.createElement("a");
    link.href = URL.createObjectURL(blob);
    link.download = `${name}_${Date.now()}.png`;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(link.href);
    
    // to enable the highlight to stay after downloading
    downloadingState = false;
  }, "image/png");
}

// Sry for horrible coding lmao
// No one just can win against Javascript that easily...