Repository: runelite/runelite
Pull request #17117 (2023-10-17 05:17:41)

Opponent Info plugin: add toggle to only show boss health overlay
Allows the Opponent Info plugin's custom health overlay to be toggled off, so that only the in-game setting 'boss health overlay' is affected.

Use case is I want to have both exact health amounts and percentage displayed on boss health bars (below), but I do not want the custom overlay displayed for non-boss monsters or players.
 ![image](https://github.com/runelite/runelite/assets/55075114/d7f12eac-426a-4898-a37d-19dcb9c6d6f7)

This is the overlay that is being made toggleable (example of an NPC and a player): 
![image](https://github.com/runelite/runelite/assets/55075114/4d5bcb2b-3e39-4ba0-810c-7bb0793c6166)
![image](https://github.com/runelite/runelite/assets/55075114/15a54af4-9b6d-4e97-b03e-a255389bc52a)

This overlay is unaffected, can still be toggled on/off independent of the new config:
![image](https://github.com/runelite/runelite/assets/55075114/9bffd970-9158-4528-97b7-6ca853e18112)


Adam-: The comment you are editing says it already does the behavior you are adding?

```java
// The in-game hp hud is more accurate than our overlay and duplicates all of the information on it,
// so hide ours if it is visible.
```
bradysauter: > The comment you are editing says it already does the behavior you are adding?
> 
> ```java
> // The in-game hp hud is more accurate than our overlay and duplicates all of the information on it,
> // so hide ours if it is visible.
> ```

The small health overlay is already hidden when there is a boss health bar visible, yes. However, there is no way to toggle off the small health overlay when interacting with players or non-boss NPCs.

I am adding the ability to always hide the small health overlay, while retaining the ability to customize the boss health overlay.

Patch:
diff --git a/pom.xml b/pom.xml
index 197688541..480db7c36 100644
--- a/pom.xml
+++ b/pom.xml
@@ -38,7 +38,7 @@
 	<properties>
 		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 		<project.build.outputTimestamp>1</project.build.outputTimestamp>
-		<lombok.version>1.18.20</lombok.version>
+		<lombok.version>1.18.30</lombok.version>
 		<logback.version>1.2.9</logback.version>
 		<slf4j.version>1.7.25</slf4j.version>
 
diff --git a/runelite-api/src/main/java/net/runelite/api/Perspective.java b/runelite-api/src/main/java/net/runelite/api/Perspective.java
index 2627d063b..4c6573da7 100644
--- a/runelite-api/src/main/java/net/runelite/api/Perspective.java
+++ b/runelite-api/src/main/java/net/runelite/api/Perspective.java
@@ -34,6 +34,7 @@ import java.util.ArrayList;
 import java.util.List;
 import javax.annotation.Nonnull;
 import javax.annotation.Nullable;
+import static net.runelite.api.Constants.EXTENDED_SCENE_SIZE;
 import static net.runelite.api.Constants.TILE_FLAG_BRIDGE;
 import net.runelite.api.coords.LocalPoint;
 import net.runelite.api.geometry.RectangleUnion;
@@ -61,6 +62,8 @@ public class Perspective
 	public static final int[] SINE = new int[2048]; // sine angles for each of the 2048 units, * 65536 and stored as an int
 	public static final int[] COSINE = new int[2048]; // cosine
 
+	private static final int ESCENE_OFFSET = (Constants.EXTENDED_SCENE_SIZE - Constants.SCENE_SIZE) / 2;
+
 	static
 	{
 		for (int i = 0; i < 2048; ++i)
@@ -117,7 +120,8 @@ public class Perspective
 	 */
 	public static Point localToCanvas(@Nonnull Client client, int x, int y, int z)
 	{
-		if (x >= 128 && y >= 128 && x <= 13056 && y <= 13056)
+		if (x >= -ESCENE_OFFSET << LOCAL_COORD_BITS && y >= -ESCENE_OFFSET << LOCAL_COORD_BITS &&
+			x <= SCENE_SIZE + ESCENE_OFFSET << LOCAL_COORD_BITS && y <= SCENE_SIZE + ESCENE_OFFSET << LOCAL_COORD_BITS)
 		{
 			x -= client.getCameraX();
 			y -= client.getCameraY();
@@ -416,19 +420,15 @@ public class Perspective
 	/**
 	 * Get the height of a location, in local coordinates. Interpolates the height from the adjacent tiles.
 	 * Does not account for bridges.
-	 * @param client
-	 * @param localX
-	 * @param localY
-	 * @param plane
 	 * @return
 	 */
-	private static int getHeight(@Nonnull Client client, int localX, int localY, int plane)
+	private static int getHeight(@Nonnull Scene scene, int localX, int localY, int plane)
 	{
-		int sceneX = localX >> LOCAL_COORD_BITS;
-		int sceneY = localY >> LOCAL_COORD_BITS;
-		if (sceneX >= 0 && sceneY >= 0 && sceneX < SCENE_SIZE && sceneY < SCENE_SIZE)
+		int sceneX = (localX >> LOCAL_COORD_BITS) + ESCENE_OFFSET;
+		int sceneY = (localY >> LOCAL_COORD_BITS) + ESCENE_OFFSET;
+		if (sceneX >= 0 && sceneY >= 0 && sceneX < Constants.EXTENDED_SCENE_SIZE && sceneY < Constants.EXTENDED_SCENE_SIZE)
 		{
-			int[][][] tileHeights = client.getTileHeights();
+			int[][][] tileHeights = scene.getTileHeights();
 
 			int x = localX & (LOCAL_TILE_SIZE - 1);
 			int y = localY & (LOCAL_TILE_SIZE - 1);
@@ -499,17 +499,20 @@ public class Perspective
 		int plane,
 		int zOffset)
 	{
-		if (!localLocation.isInScene())
+		final int msx = localLocation.getSceneX() + ESCENE_OFFSET;
+		final int msy = localLocation.getSceneY() + ESCENE_OFFSET;
+
+		if (msx < 0 || msy < 0 || msx >= EXTENDED_SCENE_SIZE || msy >= EXTENDED_SCENE_SIZE)
 		{
+			// out of scene
 			return null;
 		}
 
-		final byte[][][] tileSettings = client.getTileSettings();
-		final int sceneX = localLocation.getSceneX();
-		final int sceneY = localLocation.getSceneY();
+		var scene = client.getScene();
+		final byte[][][] tileSettings = scene.getExtendedTileSettings();
 
 		int tilePlane = plane;
-		if (plane < Constants.MAX_Z - 1 && (tileSettings[1][sceneX][sceneY] & TILE_FLAG_BRIDGE) == TILE_FLAG_BRIDGE)
+		if (plane < Constants.MAX_Z - 1 && (tileSettings[1][msx][msy] & TILE_FLAG_BRIDGE) == TILE_FLAG_BRIDGE)
 		{
 			tilePlane = plane + 1;
 		}
@@ -526,10 +529,10 @@ public class Perspective
 		final int nwX = neX;
 		final int nwY = swY;
 
-		final int swHeight = getHeight(client, swX, swY, tilePlane) - zOffset;
-		final int nwHeight = getHeight(client, nwX, nwY, tilePlane) - zOffset;
-		final int neHeight = getHeight(client, neX, neY, tilePlane) - zOffset;
-		final int seHeight = getHeight(client, seX, seY, tilePlane) - zOffset;
+		final int swHeight = getHeight(scene, swX, swY, tilePlane) - zOffset;
+		final int nwHeight = getHeight(scene, nwX, nwY, tilePlane) - zOffset;
+		final int neHeight = getHeight(scene, neX, neY, tilePlane) - zOffset;
+		final int seHeight = getHeight(scene, seX, seY, tilePlane) - zOffset;
 
 		Point p1 = localToCanvas(client, swX, swY, swHeight);
 		Point p2 = localToCanvas(client, nwX, nwY, nwHeight);
diff --git a/runelite-api/src/main/java/net/runelite/api/Scene.java b/runelite-api/src/main/java/net/runelite/api/Scene.java
index 36526b907..dc57b8711 100644
--- a/runelite-api/src/main/java/net/runelite/api/Scene.java
+++ b/runelite-api/src/main/java/net/runelite/api/Scene.java
@@ -63,6 +63,12 @@ public interface Scene
 	 */
 	void setMinLevel(int minLevel);
 
+	/**
+	 * Remove a tile from the scene
+	 * @param tile
+	 */
+	void removeTile(Tile tile);
+
 	/**
 	 * Remove a game object from the scene
 	 * @param gameObject
diff --git a/runelite-api/src/main/java/net/runelite/api/SpriteID.java b/runelite-api/src/main/java/net/runelite/api/SpriteID.java
index 0900d7ea8..bfb09fe2a 100644
--- a/runelite-api/src/main/java/net/runelite/api/SpriteID.java
+++ b/runelite-api/src/main/java/net/runelite/api/SpriteID.java
@@ -1253,7 +1253,8 @@ public final class SpriteID
 	public static final int SPELL_DARK_LURE = 1316;
 	public static final int SPELL_VILE_VIGOUR = 1317;
 	public static final int SPELL_DEGRIME = 1318;
-	/* Unmapped: 1319~1324 */
+	/* Unmapped: 1319~1323 */
+	public static final int SPELL_MARK_OF_DARKNESS_DISABLED = 1324;
 	public static final int SPELL_WARD_OF_ARCEUUS_DISABLED = 1325;
 	/* Unmapped: 1326 */
 	public static final int SPELL_GREATER_CORRUPTION_DISABLED = 1327;
diff --git a/runelite-api/src/main/java/net/runelite/api/hooks/DrawCallbacks.java b/runelite-api/src/main/java/net/runelite/api/hooks/DrawCallbacks.java
index 06bf9e206..49ee2e32e 100644
--- a/runelite-api/src/main/java/net/runelite/api/hooks/DrawCallbacks.java
+++ b/runelite-api/src/main/java/net/runelite/api/hooks/DrawCallbacks.java
@@ -68,14 +68,17 @@ public interface DrawCallbacks
 
 	/**
 	 * Called before the scene is drawn
-	 * @param cameraX
-	 * @param cameraY
-	 * @param cameraZ
-	 * @param cameraPitch
-	 * @param cameraYaw
-	 * @param plane
 	 */
-	void drawScene(int cameraX, int cameraY, int cameraZ, int cameraPitch, int cameraYaw, int plane);
+	default void drawScene(double cameraX, double cameraY, double cameraZ, double cameraPitch, double cameraYaw, int plane)
+	{
+	}
+
+	/**
+	 * Called before the scene is drawn
+	 */
+	default void drawScene(int cameraX, int cameraY, int cameraZ, int cameraPitch, int cameraYaw, int plane)
+	{
+	}
 
 	/**
 	 * Called after the scene has been drawn
diff --git a/runelite-client/src/main/java/net/runelite/client/events/ScreenshotTaken.java b/runelite-client/src/main/java/net/runelite/client/events/ScreenshotTaken.java
new file mode 100644
index 000000000..ef2951761
--- /dev/null
+++ b/runelite-client/src/main/java/net/runelite/client/events/ScreenshotTaken.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2023, Adam <Adam@sigterm.info>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ *    list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package net.runelite.client.events;
+
+import java.awt.image.BufferedImage;
+import java.io.File;
+import lombok.Value;
+
+/**
+ * Event fired when a screenshot is taken
+ */
+@Value
+public class ScreenshotTaken
+{
+	/**
+	 * Path to where the screenshot is stored
+	 */
+	File path;
+	/**
+	 * The screenshot
+	 */
+	BufferedImage screenshot;
+}
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPlugin.java
index f2ce09d2c..e54168f65 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPlugin.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPlugin.java
@@ -242,8 +242,9 @@ public class GpuPlugin extends Plugin implements DrawCallbacks
 	private AntiAliasingMode lastAntiAliasingMode;
 	private int lastAnisotropicFilteringLevel = -1;
 
-	private int yaw;
-	private int pitch;
+	private double cameraX, cameraY, cameraZ;
+	private double cameraYaw, cameraPitch;
+
 	private int viewportOffsetX;
 	private int viewportOffsetY;
 
@@ -860,10 +861,13 @@ public class GpuPlugin extends Plugin implements DrawCallbacks
 	}
 
 	@Override
-	public void drawScene(int cameraX, int cameraY, int cameraZ, int cameraPitch, int cameraYaw, int plane)
+	public void drawScene(double cameraX, double cameraY, double cameraZ, double cameraPitch, double cameraYaw, int plane)
 	{
-		yaw = client.getCameraYaw();
-		pitch = client.getCameraPitch();
+		this.cameraX = cameraX;
+		this.cameraY = cameraY;
+		this.cameraZ = cameraZ;
+		this.cameraPitch = cameraPitch;
+		this.cameraYaw = cameraYaw;
 		viewportOffsetX = client.getViewportXOffset();
 		viewportOffsetY = client.getViewportYOffset();
 
@@ -882,14 +886,14 @@ public class GpuPlugin extends Plugin implements DrawCallbacks
 		vertexBuffer.ensureCapacity(32);
 		IntBuffer uniformBuf = vertexBuffer.getBuffer();
 		uniformBuf
-			.put(yaw)
-			.put(pitch)
+			.put(client.getCameraYaw())
+			.put(client.getCameraPitch())
 			.put(client.getCenterX())
 			.put(client.getCenterY())
 			.put(client.getScale())
-			.put(cameraX)
-			.put(cameraY)
-			.put(cameraZ);
+			.put(client.getCameraX2())
+			.put(client.getCameraY2())
+			.put(client.getCameraZ2());
 		uniformBuf.flip();
 
 		GL43C.glBindBuffer(GL43C.GL_UNIFORM_BUFFER, uniformBuffer.glBufferId);
@@ -1276,9 +1280,9 @@ public class GpuPlugin extends Plugin implements DrawCallbacks
 			// Calculate projection matrix
 			float[] projectionMatrix = Mat4.scale(client.getScale(), client.getScale(), 1);
 			Mat4.mul(projectionMatrix, Mat4.projection(viewportWidth, viewportHeight, 50));
-			Mat4.mul(projectionMatrix, Mat4.rotateX((float) -(Math.PI - pitch * Perspective.UNIT)));
-			Mat4.mul(projectionMatrix, Mat4.rotateY((float) (yaw * Perspective.UNIT)));
-			Mat4.mul(projectionMatrix, Mat4.translate(-client.getCameraX2(), -client.getCameraY2(), -client.getCameraZ2()));
+			Mat4.mul(projectionMatrix, Mat4.rotateX((float) -(Math.PI - cameraPitch)));
+			Mat4.mul(projectionMatrix, Mat4.rotateY((float) cameraYaw));
+			Mat4.mul(projectionMatrix, Mat4.translate((float) -cameraX, (float) -cameraY, (float) -cameraZ));
 			GL43C.glUniformMatrix4fv(uniProjectionMatrix, false, projectionMatrix);
 
 			// Bind uniforms
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPluginConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPluginConfig.java
index aadefd615..179e25815 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPluginConfig.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/GpuPluginConfig.java
@@ -53,6 +53,17 @@ public interface GpuPluginConfig extends Config
 		return 50;
 	}
 
+	@ConfigItem(
+		keyName = "hideUnrelatedMaps",
+		name = "Hide unrelated maps",
+		description = "Hide unrelated map areas you shouldn't see.",
+		position = 2
+	)
+	default boolean hideUnrelatedMaps()
+	{
+		return true;
+	}
+
 	@Range(
 		max = 5
 	)
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/SceneUploader.java b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/SceneUploader.java
index f487f6444..f94573aec 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/SceneUploader.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/SceneUploader.java
@@ -25,6 +25,7 @@
 package net.runelite.client.plugins.gpu;
 
 import com.google.common.base.Stopwatch;
+import java.io.IOException;
 import java.util.Arrays;
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -43,30 +44,56 @@ import net.runelite.api.SceneTileModel;
 import net.runelite.api.SceneTilePaint;
 import net.runelite.api.Tile;
 import net.runelite.api.WallObject;
+import net.runelite.client.plugins.gpu.regions.Regions;
 
 @Singleton
 @Slf4j
 class SceneUploader
 {
-	@Inject
-	private Client client;
+	private final Client client;
+	private final GpuPluginConfig gpuConfig;
+
+	private Regions regions;
 
 	int sceneId = (int) System.nanoTime();
 	private int offset;
 	private int uvoffset;
 	private int uniqueModels;
 
-	void upload(Scene scene, GpuIntBuffer vertexbuffer, GpuFloatBuffer uvBuffer)
+	@Inject
+	SceneUploader(
+		Client client,
+		GpuPluginConfig config
+	)
 	{
-		Stopwatch stopwatch = Stopwatch.createStarted();
+		this.client = client;
+		this.gpuConfig = config;
+
+		try (var in = SceneUploader.class.getResourceAsStream("regions/regions.txt"))
+		{
+			regions = new Regions(in, "regions.txt");
+		}
+		catch (IOException ex)
+		{
+			throw new RuntimeException(ex);
+		}
+	}
 
+	void upload(Scene scene, GpuIntBuffer vertexBuffer, GpuFloatBuffer uvBuffer)
+	{
 		++sceneId;
 		offset = 0;
 		uvoffset = 0;
 		uniqueModels = 0;
-		vertexbuffer.clear();
+		vertexBuffer.clear();
 		uvBuffer.clear();
 
+		Stopwatch stopwatch = Stopwatch.createStarted();
+		prepare(scene);
+		stopwatch.stop();
+		log.debug("Scene preparation time: {}", stopwatch);
+
+		stopwatch = Stopwatch.createStarted();
 		for (int z = 0; z < Constants.MAX_Z; ++z)
 		{
 			for (int x = 0; x < Constants.EXTENDED_SCENE_SIZE; ++x)
@@ -76,7 +103,7 @@ class SceneUploader
 					Tile tile = scene.getExtendedTiles()[z][x][y];
 					if (tile != null)
 					{
-						upload(scene, tile, vertexbuffer, uvBuffer);
+						upload(scene, tile, vertexBuffer, uvBuffer);
 					}
 				}
 			}
@@ -1019,4 +1046,64 @@ class SceneUploader
 
 		return (hue << 10 | sat << 7 | lum) & 65535;
 	}
+
+	// remove tiles from the scene that are outside the current region
+	private void prepare(Scene scene)
+	{
+		if (scene.isInstance() || !gpuConfig.hideUnrelatedMaps())
+		{
+			return;
+		}
+
+		int baseX = scene.getBaseX() / 8;
+		int baseY = scene.getBaseY() / 8;
+		int centerX = baseX + 6;
+		int centerY = baseY + 6;
+		int centerId = regions.getRegionId(centerX, centerY);
+
+		int r = Constants.EXTENDED_SCENE_SIZE / 16;
+		for (int offx = -r; offx <= r; ++offx)
+		{
+			for (int offy = -r; offy <= r; ++offy)
+			{
+				int cx = centerX + offx;
+				int cy = centerY + offy;
+				int id = regions.getRegionId(cx, cy);
+				if (id != centerId)
+				{
+					removeChunk(scene, cx, cy);
+				}
+			}
+		}
+	}
+
+	private static void removeChunk(Scene scene, int cx, int cy)
+	{
+		int wx = cx * 8;
+		int wy = cy * 8;
+		int sx = wx - scene.getBaseX();
+		int sy = wy - scene.getBaseY();
+		int cmsx = sx + GpuPlugin.SCENE_OFFSET;
+		int cmsy = sy + GpuPlugin.SCENE_OFFSET;
+		Tile[][][] tiles = scene.getExtendedTiles();
+		for (int x = 0; x < 8; ++x)
+		{
+			for (int y = 0; y < 8; ++y)
+			{
+				int msx = cmsx + x;
+				int msy = cmsy + y;
+				if (msx >= 0 && msx < Constants.EXTENDED_SCENE_SIZE && msy >= 0 && msy < Constants.EXTENDED_SCENE_SIZE)
+				{
+					for (int z = 0; z < Constants.MAX_Z; ++z)
+					{
+						Tile tile = tiles[z][msx][msy];
+						if (tile != null)
+						{
+							scene.removeTile(tile);
+						}
+					}
+				}
+			}
+		}
+	}
 }
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/regions/Region.java b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/regions/Region.java
new file mode 100644
index 000000000..f9344364d
--- /dev/null
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/regions/Region.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2023, Adam <Adam@sigterm.info>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ *    list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package net.runelite.client.plugins.gpu.regions;
+
+class Region
+{
+	int id;
+	int cx1;
+	int cy1;
+	int cx2;
+	int cy2;
+}
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/gpu/regions/Regions.java b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/regions/Regions.java
new file mode 100644
index 000000000..0b6bd8176
--- /dev/null
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/gpu/regions/Regions.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright (c) 2023, Adam <Adam@sigterm.info>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ *    list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package net.runelite.client.plugins.gpu.regions;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class Regions
+{
+	private static final Pattern PATTERN = Pattern.compile("^[ \\t]*(?<expr>" +
+		"//.*$|" + // //comment
+		"n|" +
+		"m[ \\t]*(?<mrx>[0-9]+)[ \\t]+(?<mry>[0-9]+)|" + // m <rx> <ry>
+		"r[ \\t]*(?<rx>[0-9]+)[ \\t]+(?<ry>[0-9]+)|" + // r <rx> <ry>
+		"R[ \\t]*(?<rx1>[0-9]+)[ \\t]+(?<ry1>[0-9]+)[ \\t]+(?<rx2>[0-9]+)[ \\t]+(?<ry2>[0-9]+)|" + // R <rx1> <ry1> <rx2> <ry2>
+		"c[ \\t]*(?<cx>[0-9-]+)[ \\t]+(?<cy>[0-9-]+)|" + // c <cx> <cy>
+		"C[ \\t]*(?<cx1>[0-9-]+)[ \\t]+(?<cy1>[0-9-]+)[ \\t]+(?<cx2>[0-9-]+)[ \\t]+(?<cy2>[0-9-]+)|" + // C <cx1> <cy1> <cx2> <cy2>
+		")[ \\t]*");
+
+	@Getter(AccessLevel.PACKAGE)
+	private final List<Region> regions = new ArrayList<>();
+
+	private static final int REGION_MAP_SIZE = 70 * 200 / 8;
+	private final byte[] regionMap = new byte[REGION_MAP_SIZE];
+
+	private Region last;
+
+	public Regions(InputStream is, String filename) throws IOException
+	{
+		this(new InputStreamReader(is, StandardCharsets.UTF_8), filename);
+	}
+
+	public Regions(Reader reader, String filename) throws IOException
+	{
+		BufferedReader br = new BufferedReader(reader);
+		int lineNo = 1;
+		int rx1 = 0, ry1 = 0, rx2 = 0, ry2 = 0;
+		int rid = 0;
+		Matcher m = PATTERN.matcher("");
+		for (String line; (line = br.readLine()) != null; lineNo++)
+		{
+			m.reset(line);
+			int end = 0;
+			while (end < line.length())
+			{
+				m.region(end, line.length());
+				if (!m.find())
+				{
+					throw new IllegalArgumentException("Unexpected: \"" + line.substring(end) + "\" (" + filename + ":" + lineNo + ")");
+				}
+				end = m.end();
+
+				String expr = m.group("expr");
+				if (expr == null || expr.length() <= 0 || expr.startsWith("//"))
+				{
+					continue;
+				}
+
+				char cha = expr.charAt(0);
+				switch (cha)
+				{
+					case 'n':
+						rid++;
+						break;
+					case 'm':
+						rx2 = rx1 = Integer.parseInt(m.group("mrx"));
+						ry2 = ry1 = Integer.parseInt(m.group("mry"));
+						break;
+					case 'r':
+					case 'R':
+						if (cha == 'r')
+						{
+							rx2 = rx1 = Integer.parseInt(m.group("rx"));
+							ry2 = ry1 = Integer.parseInt(m.group("ry"));
+						}
+						else
+						{
+							rx1 = Integer.parseInt(m.group("rx1"));
+							ry1 = Integer.parseInt(m.group("ry1"));
+							rx2 = Integer.parseInt(m.group("rx2"));
+							ry2 = Integer.parseInt(m.group("ry2"));
+						}
+						// fallthrough
+					case 'c':
+					case 'C':
+						int cx1 = rx1 * 8;
+						int cy1 = ry1 * 8;
+						int cx2 = rx2 * 8 + 7;
+						int cy2 = ry2 * 8 + 7;
+						if (cha == 'c')
+						{
+							cx2 = cx1 = cx1 + Integer.parseInt(m.group("cx"));
+							cy2 = cy1 = cy1 + Integer.parseInt(m.group("cy"));
+						}
+						else if (cha == 'C')
+						{
+							cx2 = cx1 + Integer.parseInt(m.group("cx2"));
+							cy2 = cy1 + Integer.parseInt(m.group("cy2"));
+							cx1 = cx1 + Integer.parseInt(m.group("cx1"));
+							cy1 = cy1 + Integer.parseInt(m.group("cy1"));
+						}
+
+						if (cx1 > cx2 || cy1 > cy2)
+						{
+							throw new IllegalArgumentException("First coord must be before second (" + filename + ":" + lineNo + ")");
+						}
+
+						Region r = new Region();
+						r.id = rid;
+						r.cx1 = cx1;
+						r.cy1 = cy1;
+						r.cx2 = cx2;
+						r.cy2 = cy2;
+						regions.add(r);
+
+						// mark the affected regions as in uses
+						for (int rx = rx1; rx <= rx2; ++rx)
+						{
+							for (int ry = ry1; ry <= ry2; ++ry)
+							{
+								markRegion(rx, ry);
+							}
+						}
+				}
+			}
+		}
+	}
+
+	public int getRegionId(int cx, int cy)
+	{
+		int rx = cx / 8;
+		int ry = cy / 8;
+		if (!isMarked(rx, ry))
+		{
+			return 0;
+		}
+
+		if (last != null && intersects(cx, cy, last))
+		{
+			return last.id;
+		}
+
+		for (Region r : regions)
+		{
+			if (intersects(cx, cy, r))
+			{
+				last = r;
+				return r.id;
+			}
+		}
+		return 0;
+	}
+
+	private static boolean intersects(int cx, int cy, Region r)
+	{
+		return cx >= r.cx1 && cy >= r.cy1 && cx <= r.cx2 && cy <= r.cy2;
+	}
+
+	private void markRegion(int rx, int ry)
+	{
+		int id = (rx << 8) | ry;
+		int idx = id / 8;
+		if (idx < regionMap.length)
+		{
+			regionMap[idx] |= 1 << (idx % 8);
+		}
+	}
+
+	private boolean isMarked(int rx, int ry)
+	{
+		int id = (rx << 8) | ry;
+		int idx = id / 8;
+		if (idx < regionMap.length)
+		{
+			return (regionMap[idx] & (1 << (idx % 8))) != 0;
+		}
+		return true;
+	}
+}
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/itemcharges/ItemChargePlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/itemcharges/ItemChargePlugin.java
index 5cd6d26e1..7deeefb88 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/itemcharges/ItemChargePlugin.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/itemcharges/ItemChargePlugin.java
@@ -484,8 +484,18 @@ public class ItemChargePlugin extends Plugin
 				// Determine if the player mined with a Bracelet of Clay equipped.
 				if (equipment != null && equipment.contains(ItemID.BRACELET_OF_CLAY))
 				{
-					int charges = Ints.constrainToRange(getItemCharges(ItemChargeConfig.KEY_BRACELET_OF_CLAY) - 1, 0, MAX_BRACELET_OF_CLAY_CHARGES);
-					updateBraceletOfClayCharges(charges);
+					final ItemContainer inventory = client.getItemContainer(InventoryID.INVENTORY);
+
+					// Charge is not used if only 1 inventory slot is available when mining in Prifddinas
+					boolean ignore = inventory != null
+						&& inventory.count() == 27
+						&& message.equals(BRACELET_OF_CLAY_USE_TEXT_TRAHAEARN);
+
+					if (!ignore)
+					{
+						int charges = Ints.constrainToRange(getItemCharges(ItemChargeConfig.KEY_BRACELET_OF_CLAY) - 1, 0, MAX_BRACELET_OF_CLAY_CHARGES);
+						updateBraceletOfClayCharges(charges);
+					}
 				}
 			}
 			else if (message.equals(BRACELET_OF_CLAY_BREAK_TEXT))
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoConfig.java
index ce0c565c0..d4bc13587 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoConfig.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoConfig.java
@@ -63,4 +63,15 @@ public interface OpponentInfoConfig extends Config
 	{
 		return false;
 	}
+
+	@ConfigItem(
+		keyName = "showOpponentHealthOverlay",
+		name = "Show opponent health overlay",
+		description = "Shows a health bar overlay when a boss health overlay is not present",
+		position = 4
+	)
+	default boolean showOpponentHealthOverlay()
+	{
+		return true;
+	}
 }
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoOverlay.java b/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoOverlay.java
index 6b73f4945..fdb6619e8 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoOverlay.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/opponentinfo/OpponentInfoOverlay.java
@@ -140,8 +140,8 @@ class OpponentInfoOverlay extends OverlayPanel
 		}
 
 		// The in-game hp hud is more accurate than our overlay and duplicates all of the information on it,
-		// so hide ours if it is visible.
-		if (opponentName == null || hasHpHud(opponent))
+		// so hide ours if it is visible, or if our overlay is toggled off.
+		if (opponentName == null || hasHpHud(opponent) || !opponentInfoConfig.showOpponentHealthOverlay())
 		{
 			return null;
 		}
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsConfig.java
index 3abb6eb4f..bd51f6eac 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsConfig.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsConfig.java
@@ -28,7 +28,6 @@ import net.runelite.client.config.Config;
 import net.runelite.client.config.ConfigGroup;
 import net.runelite.client.config.ConfigItem;
 import net.runelite.client.config.Keybind;
-import net.runelite.client.util.ImageUploadStyle;
 
 @ConfigGroup("raids")
 public interface RaidsConfig extends Config
@@ -189,12 +188,12 @@ public interface RaidsConfig extends Config
 
 	@ConfigItem(
 		position = 14,
-		keyName = "uploadScreenshot",
-		name = "Upload screenshot",
-		description = "Uploads the scouting screenshot to Imgur or the clipboard"
+		keyName = "copyToClipboard",
+		name = "Copy to clipboard",
+		description = "Copies the scouting screenshot to clipboard"
 	)
-	default ImageUploadStyle uploadScreenshot()
+	default boolean copyToClipboard()
 	{
-		return ImageUploadStyle.CLIPBOARD;
+		return true;
 	}
 }
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsPlugin.java
index cfe6d7ba4..60f95f239 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsPlugin.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/raids/RaidsPlugin.java
@@ -848,7 +848,7 @@ public class RaidsPlugin extends Plugin
 		graphic.fillRect(0, 0, overlayDimensions.width, overlayDimensions.height);
 		overlay.render(graphic);
 
-		imageCapture.takeScreenshot(overlayImage, "CoX_scout-", false, config.uploadScreenshot());
+		imageCapture.saveScreenshot(overlayImage, "CoX_scout", null, false, config.copyToClipboard());
 		graphic.dispose();
 	}
 
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotConfig.java
index e7bfdc300..a39a531d6 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotConfig.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotConfig.java
@@ -29,7 +29,6 @@ import net.runelite.client.config.ConfigGroup;
 import net.runelite.client.config.ConfigItem;
 import net.runelite.client.config.ConfigSection;
 import net.runelite.client.config.Keybind;
-import net.runelite.client.util.ImageUploadStyle;
 
 @ConfigGroup("screenshot")
 public interface ScreenshotConfig extends Config
@@ -75,14 +74,14 @@ public interface ScreenshotConfig extends Config
 	}
 
 	@ConfigItem(
-		keyName = "uploadScreenshot",
-		name = "Upload",
-		description = "Configures whether or not screenshots are uploaded to Imgur, or placed on your clipboard",
-		position = 3
+		keyName = "copyToClipboard",
+		name = "Copy to clipboard",
+		description = "Copies the saved screenshot to clipboard",
+		position = 4
 	)
-	default ImageUploadStyle uploadScreenshot()
+	default boolean copyToClipboard()
 	{
-		return ImageUploadStyle.NEITHER;
+		return false;
 	}
 
 	@ConfigItem(
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotPlugin.java
index 96ce9a786..c53e54f47 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotPlugin.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/screenshot/ScreenshotPlugin.java
@@ -29,11 +29,8 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.inject.Provides;
-import java.awt.Graphics2D;
 import java.awt.Image;
-import java.awt.geom.AffineTransform;
 import java.awt.image.BufferedImage;
-import java.lang.reflect.InvocationTargetException;
 import java.time.LocalDate;
 import java.util.Map;
 import java.util.Set;
@@ -42,7 +39,6 @@ import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import javax.inject.Inject;
-import javax.swing.SwingUtilities;
 import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.extern.slf4j.Slf4j;
@@ -51,7 +47,6 @@ import net.runelite.api.ChatMessageType;
 import net.runelite.api.Client;
 import net.runelite.api.GameState;
 import net.runelite.api.Player;
-import net.runelite.api.Point;
 import net.runelite.api.ScriptID;
 import net.runelite.api.SpriteID;
 import net.runelite.api.VarClientStr;
@@ -84,7 +79,6 @@ import net.runelite.client.input.KeyManager;
 import net.runelite.client.plugins.Plugin;
 import net.runelite.client.plugins.PluginDescriptor;
 import net.runelite.client.ui.ClientToolbar;
-import net.runelite.client.ui.ClientUI;
 import net.runelite.client.ui.DrawManager;
 import net.runelite.client.ui.NavigationButton;
 import net.runelite.client.ui.overlay.OverlayManager;
@@ -92,14 +86,13 @@ import net.runelite.client.util.HotkeyListener;
 import net.runelite.client.util.ImageCapture;
 import net.runelite.client.util.ImageUtil;
 import net.runelite.client.util.LinkBrowser;
-import net.runelite.client.util.OSType;
 import net.runelite.client.util.Text;
 import org.apache.commons.lang3.StringUtils;
 
 @PluginDescriptor(
 	name = "Screenshot",
 	description = "Enable the manual and automatic taking of screenshots",
-	tags = {"external", "images", "imgur", "integration", "notifications"}
+	tags = {"external", "images", "integration", "notifications"}
 )
 @Slf4j
 public class ScreenshotPlugin extends Plugin
@@ -178,9 +171,6 @@ public class ScreenshotPlugin extends Plugin
 	@Inject
 	private Client client;
 
-	@Inject
-	private ClientUI clientUi;
-
 	@Inject
 	private ClientToolbar clientToolbar;
 
@@ -885,7 +875,7 @@ public class ScreenshotPlugin extends Plugin
 		Consumer<Image> imageCallback = (img) ->
 		{
 			// This callback is on the game thread, move to executor thread
-			executor.submit(() -> takeScreenshot(fileName, subDir, img));
+			executor.submit(() -> saveScreenshot(fileName, subDir, img));
 		};
 
 		if (config.displayDate() && REPORT_BUTTON_TLIS.contains(client.getTopLevelInterfaceId()))
@@ -898,12 +888,7 @@ public class ScreenshotPlugin extends Plugin
 		}
 	}
 
-	private static int getScaledValue(final double scale, final int value)
-	{
-		return (int) (value * scale + .5);
-	}
-
-	private void takeScreenshot(String fileName, String subDir, Image image)
+	private void saveScreenshot(String fileName, String subDir, Image image)
 	{
 		final BufferedImage screenshot;
 		if (!config.includeFrame())
@@ -913,43 +898,10 @@ public class ScreenshotPlugin extends Plugin
 		}
 		else
 		{
-			// create a new image, paint the client ui to it, and then draw the screenshot to that
-			final AffineTransform transform = OSType.getOSType() == OSType.MacOS ? new AffineTransform() :
-				clientUi.getGraphicsConfiguration().getDefaultTransform();
-
-			// scaled client dimensions
-			int clientWidth = getScaledValue(transform.getScaleX(), clientUi.getWidth());
-			int clientHeight = getScaledValue(transform.getScaleY(), clientUi.getHeight());
-
-			screenshot = new BufferedImage(clientWidth, clientHeight, BufferedImage.TYPE_INT_ARGB);
-
-			Graphics2D graphics = (Graphics2D) screenshot.getGraphics();
-			AffineTransform originalTransform = graphics.getTransform();
-			// scale g2d for the paint() call
-			graphics.setTransform(transform);
-
-			// Draw the client frame onto the screenshot
-			try
-			{
-				SwingUtilities.invokeAndWait(() -> clientUi.paint(graphics));
-			}
-			catch (InterruptedException | InvocationTargetException e)
-			{
-				log.warn("unable to paint client UI on screenshot", e);
-			}
-
-			// Find the position of the canvas inside the frame
-			final Point canvasOffset = clientUi.getCanvasOffset();
-			final int gameOffsetX = getScaledValue(transform.getScaleX(), canvasOffset.getX());
-			final int gameOffsetY = getScaledValue(transform.getScaleY(), canvasOffset.getY());
-
-			// Draw the original screenshot onto the new screenshot
-			graphics.setTransform(originalTransform); // the original screenshot is already scaled
-			graphics.drawImage(image, gameOffsetX, gameOffsetY, null);
-			graphics.dispose();
+			screenshot = imageCapture.addClientFrame(image);
 		}
 
-		imageCapture.takeScreenshot(screenshot, fileName, subDir, config.notifyWhenTaken(), config.uploadScreenshot());
+		imageCapture.saveScreenshot(screenshot, fileName, subDir, config.notifyWhenTaken(), config.copyToClipboard());
 	}
 
 	private boolean isInsideGauntlet()
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java b/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java
index 1e47c34c9..a7b14b110 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/timers/GameTimer.java
@@ -82,11 +82,13 @@ enum GameTimer
 	RESURRECT_THRALL(SpriteID.SPELL_RESURRECT_SUPERIOR_SKELETON, GameTimerImageType.SPRITE, "Resurrect thrall", false),
 	WARD_OF_ARCEUUS(SpriteID.SPELL_WARD_OF_ARCEUUS, GameTimerImageType.SPRITE, "Ward of Arceuus", true),
 	DEATH_CHARGE(SpriteID.SPELL_DEATH_CHARGE, GameTimerImageType.SPRITE, "Death charge", false),
+	MARK_OF_DARKNESS(SpriteID.SPELL_MARK_OF_DARKNESS, GameTimerImageType.SPRITE, "Mark of Darkness", true),
 	SHADOW_VEIL_COOLDOWN(SpriteID.SPELL_SHADOW_VEIL_DISABLED, GameTimerImageType.SPRITE, "Shadow veil cooldown", 30, ChronoUnit.SECONDS),
 	RESURRECT_THRALL_COOLDOWN(SpriteID.SPELL_RESURRECT_SUPERIOR_SKELETON_DISABLED, GameTimerImageType.SPRITE, "Resurrect thrall cooldown", 17, GAME_TICKS),
 	WARD_OF_ARCEUUS_COOLDOWN(SpriteID.SPELL_WARD_OF_ARCEUUS_DISABLED, GameTimerImageType.SPRITE, "Ward of Arceuus cooldown", 30, ChronoUnit.SECONDS),
 	DEATH_CHARGE_COOLDOWN(SpriteID.SPELL_DEATH_CHARGE_DISABLED, GameTimerImageType.SPRITE, "Death charge cooldown", 60, ChronoUnit.SECONDS),
 	CORRUPTION_COOLDOWN(SpriteID.SPELL_GREATER_CORRUPTION_DISABLED, GameTimerImageType.SPRITE, "Corruption cooldown", 30, ChronoUnit.SECONDS),
+	MARK_OF_DARKNESS_COOLDOWN(SpriteID.SPELL_MARK_OF_DARKNESS_DISABLED, GameTimerImageType.SPRITE, "Mark of Darkness cooldown", 90, GAME_TICKS, true),
 	PICKPOCKET_STUN(SpriteID.SKILL_THIEVING, GameTimerImageType.SPRITE, "Stunned", true),
 	FARMERS_AFFINITY(ItemID.GRAIN, GameTimerImageType.ITEM, "Farmer's Affinity", false),
 	SMELLING_SALTS(ItemID.SMELLING_SALTS_2, GameTimerImageType.ITEM, "Smelling salts", true),
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java
index 10ecba8cf..1c4a77ef2 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/timers/TimersPlugin.java
@@ -107,6 +107,7 @@ public class TimersPlugin extends Plugin
 	private static final String RESURRECT_THRALL_MESSAGE_START = ">You resurrect a ";
 	private static final String RESURRECT_THRALL_MESSAGE_END = " thrall.</col>";
 	private static final String WARD_OF_ARCEUUS_MESSAGE = ">Your defence against Arceuus magic has been strengthened.</col>";
+	private static final String MARK_OF_DARKNESS_MESSAGE = "You have placed a Mark of Darkness upon yourself.</col>";
 	private static final String PICKPOCKET_FAILURE_MESSAGE = "You fail to pick ";
 	private static final String DODGY_NECKLACE_PROTECTION_MESSAGE = "Your dodgy necklace protects you.";
 	private static final String SHADOW_VEIL_PROTECTION_MESSAGE = "Your attempt to steal goes unnoticed.";
@@ -681,6 +682,7 @@ public class TimersPlugin extends Plugin
 			removeGameTimer(RESURRECT_THRALL);
 			removeGameTimer(SHADOW_VEIL);
 			removeGameTimer(WARD_OF_ARCEUUS);
+			removeGameTimer(MARK_OF_DARKNESS);
 		}
 
 		if (!config.showArceuusCooldown())
@@ -690,6 +692,7 @@ public class TimersPlugin extends Plugin
 			removeGameTimer(SHADOW_VEIL_COOLDOWN);
 			removeGameTimer(WARD_OF_ARCEUUS_COOLDOWN);
 			removeGameTimer(CORRUPTION_COOLDOWN);
+			removeGameTimer(MARK_OF_DARKNESS_COOLDOWN);
 		}
 
 		if (!config.showAntiPoison())
@@ -829,6 +832,10 @@ public class TimersPlugin extends Plugin
 			{
 				createGameTimer(WARD_OF_ARCEUUS, Duration.of(magicLevel, RSTimeUnit.GAME_TICKS));
 			}
+			else if (message.endsWith(MARK_OF_DARKNESS_MESSAGE))
+			{
+				createGameTimer(MARK_OF_DARKNESS, Duration.of(magicLevel, RSTimeUnit.GAME_TICKS));
+			}
 			else if (message.contains(RESURRECT_THRALL_MESSAGE_START) && message.endsWith(RESURRECT_THRALL_MESSAGE_END))
 			{
 				// by default the thrall lasts 1 tick per magic level
@@ -846,6 +853,15 @@ public class TimersPlugin extends Plugin
 			}
 		}
 
+		if (config.showArceuusCooldown())
+		{
+			final int magicLevel = client.getRealSkillLevel(Skill.MAGIC);
+			if (message.endsWith(MARK_OF_DARKNESS_MESSAGE))
+			{
+				createGameTimer(MARK_OF_DARKNESS_COOLDOWN, Duration.of(magicLevel - 10, RSTimeUnit.GAME_TICKS));
+			}
+		}
+
 		if (message.equals(TZHAAR_DEFEATED_MESSAGE))
 		{
 			log.debug("Stopping tzhaar timer");
diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/xpupdater/XpUpdaterPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/xpupdater/XpUpdaterPlugin.java
index 33eae3111..fc74a412d 100644
--- a/runelite-client/src/main/java/net/runelite/client/plugins/xpupdater/XpUpdaterPlugin.java
+++ b/runelite-client/src/main/java/net/runelite/client/plugins/xpupdater/XpUpdaterPlugin.java
@@ -102,7 +102,7 @@ public class XpUpdaterPlugin extends Plugin
 				fetchXp = true;
 			}
 		}
-		else if (state == GameState.LOGIN_SCREEN)
+		else if (state == GameState.LOGIN_SCREEN || state == GameState.HOPPING)
 		{
 			Player local = client.getLocalPlayer();
 			if (local == null)
diff --git a/runelite-client/src/main/java/net/runelite/client/util/ImageCapture.java b/runelite-client/src/main/java/net/runelite/client/util/ImageCapture.java
index dff5c3416..038a1a3e4 100644
--- a/runelite-client/src/main/java/net/runelite/client/util/ImageCapture.java
+++ b/runelite-client/src/main/java/net/runelite/client/util/ImageCapture.java
@@ -26,89 +26,155 @@
 package net.runelite.client.util;
 
 import com.google.common.base.Strings;
-import com.google.gson.Gson;
+import java.awt.Graphics2D;
+import java.awt.Image;
 import java.awt.Toolkit;
 import java.awt.TrayIcon;
 import java.awt.datatransfer.Clipboard;
-import java.awt.datatransfer.StringSelection;
+import java.awt.geom.AffineTransform;
 import java.awt.image.BufferedImage;
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.nio.charset.StandardCharsets;
-import java.nio.file.Files;
+import java.lang.reflect.InvocationTargetException;
 import java.text.DateFormat;
 import java.text.SimpleDateFormat;
-import java.util.Base64;
 import java.util.Date;
+import java.util.concurrent.ScheduledExecutorService;
 import javax.annotation.Nullable;
 import javax.imageio.ImageIO;
 import javax.inject.Inject;
-import javax.inject.Named;
 import javax.inject.Singleton;
-import lombok.Data;
+import javax.swing.SwingUtilities;
+import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import net.runelite.api.Client;
 import net.runelite.api.GameState;
+import net.runelite.api.Point;
 import net.runelite.client.Notifier;
 import static net.runelite.client.RuneLite.SCREENSHOT_DIR;
 import net.runelite.client.config.RuneScapeProfileType;
-import okhttp3.Call;
-import okhttp3.Callback;
-import okhttp3.HttpUrl;
-import okhttp3.MediaType;
-import okhttp3.OkHttpClient;
-import okhttp3.Request;
-import okhttp3.RequestBody;
-import okhttp3.Response;
+import net.runelite.client.eventbus.EventBus;
+import net.runelite.client.events.ScreenshotTaken;
+import net.runelite.client.ui.ClientUI;
+import net.runelite.client.ui.DrawManager;
 
 @Slf4j
 @Singleton
+@RequiredArgsConstructor(onConstructor = @__(@Inject))
 public class ImageCapture
 {
 	private static final DateFormat TIME_FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
-	private static final HttpUrl IMGUR_IMAGE_UPLOAD_URL = HttpUrl.get("https://api.imgur.com/3/image");
-	private static final MediaType JSON = MediaType.get("application/json");
 
 	private final Client client;
 	private final Notifier notifier;
-	private final OkHttpClient okHttpClient;
-	private final Gson gson;
-	private final String imgurClientId;
+	private final ClientUI clientUi;
+	private final DrawManager drawManager;
+	private final ScheduledExecutorService executor;
+	private final EventBus eventBus;
 
-	@Inject
-	private ImageCapture(
-		final Client client,
-		final Notifier notifier,
-		final OkHttpClient okHttpClient,
-		final Gson gson,
-		@Named("runelite.imgur.client.id") final String imgurClientId
-	)
+	/**
+	 * Take a screenshot and save it
+	 * @param subDir the subdirectory to save the screenshot in
+	 * @param fileName the filename for the screenshot
+	 * @param includeClientFrame whether to include the client ui in the screenshot
+	 * @param notify whether to send a notification
+	 * @param copyToClipboard whether to copy the screenshot to clipboard
+	 */
+	public void takeScreenshot(@Nullable String subDir, String fileName, boolean includeClientFrame,
+		boolean notify, boolean copyToClipboard)
 	{
-		this.client = client;
-		this.notifier = notifier;
-		this.okHttpClient = okHttpClient;
-		this.gson = gson;
-		this.imgurClientId = imgurClientId;
+		drawManager.requestNextFrameListener((img) ->
+		{
+			// This callback is on the client thread, move to executor thread now that we have the screenshot
+			executor.submit(() ->
+			{
+				final BufferedImage screenshot;
+				if (includeClientFrame)
+				{
+					screenshot = addClientFrame(img);
+				}
+				else
+				{
+					screenshot = ImageUtil.bufferedImageFromImage(img);
+				}
+
+				saveScreenshot(screenshot, fileName, subDir, notify, copyToClipboard);
+			});
+		});
 	}
 
 	/**
-	 * Saves a screenshot of the client window to the screenshot folder as a PNG,
-	 * and optionally uploads it to an image-hosting service.
+	 * Add the client frame to a screenshot
 	 *
-	 * @param screenshot BufferedImage to capture.
+	 * @param image the screenshot
+	 * @return
+	 */
+	public BufferedImage addClientFrame(Image image)
+	{
+		// create a new image, paint the client ui to it, and then draw the screenshot to that
+		final AffineTransform transform = OSType.getOSType() == OSType.MacOS ? new AffineTransform() :
+			clientUi.getGraphicsConfiguration().getDefaultTransform();
+
+		// scaled client dimensions
+		int clientWidth = getScaledValue(transform.getScaleX(), clientUi.getWidth());
+		int clientHeight = getScaledValue(transform.getScaleY(), clientUi.getHeight());
+
+		final BufferedImage screenshot = new BufferedImage(clientWidth, clientHeight, BufferedImage.TYPE_INT_ARGB);
+
+		Graphics2D graphics = (Graphics2D) screenshot.getGraphics();
+		AffineTransform originalTransform = graphics.getTransform();
+		// scale g2d for the paint() call
+		graphics.setTransform(transform);
+
+		// Draw the client frame onto the screenshot
+		try
+		{
+			SwingUtilities.invokeAndWait(() -> clientUi.paint(graphics));
+		}
+		catch (InterruptedException | InvocationTargetException e)
+		{
+			log.warn("unable to paint client UI on screenshot", e);
+		}
+
+		// Find the position of the canvas inside the frame
+		final Point canvasOffset = clientUi.getCanvasOffset();
+		final int gameOffsetX = getScaledValue(transform.getScaleX(), canvasOffset.getX());
+		final int gameOffsetY = getScaledValue(transform.getScaleY(), canvasOffset.getY());
+
+		// Draw the original screenshot onto the new screenshot
+		graphics.setTransform(originalTransform); // the original screenshot is already scaled
+		graphics.drawImage(image, gameOffsetX, gameOffsetY, null);
+		graphics.dispose();
+
+		return screenshot;
+	}
+
+	private static int getScaledValue(final double scale, final int value)
+	{
+		return (int) (value * scale + .5);
+	}
+
+	/**
+	 * Save a screenshot to disk. And optionally send a notification and copy it to clipboard.
+	 *
+	 * @param screenshot screenshot
 	 * @param fileName Filename to use, without file extension.
 	 * @param subDir Directory within the player screenshots dir to store the captured screenshot to.
 	 * @param notify Send a notification to the system tray when the image is captured.
-	 * @param imageUploadStyle which method to use to upload the screenshot (Imgur or directly to clipboard).
+	 * @param saveToClipboard Whether to also save the screenshot to clipboard
 	 */
-	public void takeScreenshot(BufferedImage screenshot, String fileName, @Nullable String subDir, boolean notify, ImageUploadStyle imageUploadStyle)
+	public void saveScreenshot(
+		BufferedImage screenshot,
+		String fileName,
+		@Nullable String subDir,
+		boolean notify,
+		boolean saveToClipboard
+	)
 	{
 		if (client.getGameState() == GameState.LOGIN_SCREEN)
 		{
 			// Prevent the screenshot from being captured
-			log.info("Login screenshot prevented");
+			log.debug("Login screenshot prevented");
 			return;
 		}
 
@@ -138,109 +204,70 @@ public class ImageCapture
 
 		fileName += (fileName.isEmpty() ? "" : " ") + format(new Date());
 
-		try
+		File screenshotFile = new File(playerFolder, fileName + ".png");
+		// To make sure that screenshots don't get overwritten, check if file exists,
+		// and if it does create file with same name and suffix.
+		int i = 1;
+		while (screenshotFile.exists())
 		{
-			File screenshotFile = new File(playerFolder, fileName + ".png");
-
-			// To make sure that screenshots don't get overwritten, check if file exists,
-			// and if it does create file with same name and suffix.
-			int i = 1;
-			while (screenshotFile.exists())
-			{
-				screenshotFile = new File(playerFolder, fileName + String.format("(%d)", i++) + ".png");
-			}
+			screenshotFile = new File(playerFolder, fileName + String.format("(%d)", i++) + ".png");
+		}
 
+		try
+		{
 			ImageIO.write(screenshot, "PNG", screenshotFile);
+		}
+		catch (IOException ex)
+		{
+			log.error("error writing screenshot", ex);
+			return;
+		}
 
-			if (imageUploadStyle == ImageUploadStyle.IMGUR)
-			{
-				uploadScreenshot(screenshotFile, notify);
-			}
-			else if (imageUploadStyle == ImageUploadStyle.CLIPBOARD)
-			{
-				Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
-				TransferableBufferedImage transferableBufferedImage = new TransferableBufferedImage(screenshot);
-				clipboard.setContents(transferableBufferedImage, null);
+		if (saveToClipboard)
+		{
+			Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
+			TransferableBufferedImage transferableBufferedImage = new TransferableBufferedImage(screenshot);
+			clipboard.setContents(transferableBufferedImage, null);
 
-				if (notify)
-				{
-					notifier.notify("A screenshot was saved and inserted into your clipboard!", TrayIcon.MessageType.INFO);
-				}
-			}
-			else if (notify)
+			if (notify)
 			{
-				notifier.notify("A screenshot was saved to " + screenshotFile, TrayIcon.MessageType.INFO);
+				notifier.notify("A screenshot was saved and inserted into your clipboard!", TrayIcon.MessageType.INFO);
 			}
 		}
-		catch (IOException ex)
+		else if (notify)
 		{
-			log.warn("error writing screenshot", ex);
+			notifier.notify("A screenshot was saved to " + screenshotFile, TrayIcon.MessageType.INFO);
 		}
+
+		ScreenshotTaken screenshotTaken = new ScreenshotTaken(
+			screenshotFile,
+			screenshot
+		);
+		eventBus.post(screenshotTaken);
 	}
 
 	/**
-	 * Saves a screenshot of the client window to the screenshot folder as a PNG,
-	 * and optionally uploads it to an image-hosting service.
-	 *
-	 * @param screenshot BufferedImage to capture.
-	 * @param fileName Filename to use, without file extension.
-	 * @param notify Send a notification to the system tray when the image is captured.
-	 * @param imageUploadStyle which method to use to upload the screenshot (Imgur or directly to clipboard).
+	 * Saves a screenshot to the screenshots folder as a PNG, and fires a ScreenshotTaken
+	 * event afterward.
+	 * @deprecated This method formerly could upload the image to Imgur, which is no longer supported. Use saveScreenshot instead.
+	 * @see #saveScreenshot(BufferedImage, String, String, boolean, boolean)
 	 */
-	public void takeScreenshot(BufferedImage screenshot, String fileName, boolean notify, ImageUploadStyle imageUploadStyle)
+	@Deprecated
+	public void takeScreenshot(BufferedImage screenshot, String fileName, @Nullable String subDir, boolean notify, ImageUploadStyle imageUploadStyle)
 	{
-		takeScreenshot(screenshot, fileName, null, notify, imageUploadStyle);
+		saveScreenshot(screenshot, fileName, subDir, notify, imageUploadStyle == ImageUploadStyle.CLIPBOARD);
 	}
 
 	/**
-	 * Uploads a screenshot to the Imgur image-hosting service,
-	 * and copies the image link to the clipboard.
-	 *
-	 * @param screenshotFile Image file to upload.
-	 * @throws IOException Thrown if the file cannot be read.
+	  * Saves a screenshot to the screenshots folder as a PNG, and fires a ScreenshotTaken
+	  * event afterward.
+	 * @deprecated This method formerly could upload the image to Imgur, which is no longer supported. Use saveScreenshot instead.
+	 * @see #saveScreenshot(BufferedImage, String, String, boolean, boolean)
 	 */
-	private void uploadScreenshot(File screenshotFile, boolean notify) throws IOException
+	@Deprecated
+	public void takeScreenshot(BufferedImage screenshot, String fileName, boolean notify, ImageUploadStyle imageUploadStyle)
 	{
-		String json = gson.toJson(new ImageUploadRequest(screenshotFile));
-
-		Request request = new Request.Builder()
-			.url(IMGUR_IMAGE_UPLOAD_URL)
-			.addHeader("Authorization", "Client-ID " + imgurClientId)
-			.post(RequestBody.create(JSON, json))
-			.build();
-
-		okHttpClient.newCall(request).enqueue(new Callback()
-		{
-			@Override
-			public void onFailure(Call call, IOException ex)
-			{
-				log.warn("error uploading screenshot", ex);
-			}
-
-			@Override
-			public void onResponse(Call call, Response response) throws IOException
-			{
-				try (InputStream in = response.body().byteStream())
-				{
-					ImageUploadResponse imageUploadResponse =
-						gson.fromJson(new InputStreamReader(in, StandardCharsets.UTF_8), ImageUploadResponse.class);
-
-					if (imageUploadResponse.isSuccess())
-					{
-						String link = imageUploadResponse.getData().getLink();
-
-						StringSelection selection = new StringSelection(link);
-						Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
-						clipboard.setContents(selection, selection);
-
-						if (notify)
-						{
-							notifier.notify("A screenshot was uploaded and inserted into your clipboard!", TrayIcon.MessageType.INFO);
-						}
-					}
-				}
-			}
-		});
+		takeScreenshot(screenshot, fileName, null, notify, imageUploadStyle);
 	}
 
 	private static String format(Date date)
@@ -250,30 +277,4 @@ public class ImageCapture
 			return TIME_FORMAT.format(date);
 		}
 	}
-
-	@Data
-	private static class ImageUploadResponse
-	{
-		private Data data;
-		private boolean success;
-
-		@lombok.Data
-		private static class Data
-		{
-			private String link;
-		}
-	}
-
-	@Data
-	private static class ImageUploadRequest
-	{
-		private final String image;
-		private final String type;
-
-		ImageUploadRequest(File imageFile) throws IOException
-		{
-			this.image = Base64.getEncoder().encodeToString(Files.readAllBytes(imageFile.toPath()));
-			this.type = "base64";
-		}
-	}
 }
diff --git a/runelite-client/src/main/java/net/runelite/client/util/ImageUploadStyle.java b/runelite-client/src/main/java/net/runelite/client/util/ImageUploadStyle.java
index 95c75536a..fa9e04456 100644
--- a/runelite-client/src/main/java/net/runelite/client/util/ImageUploadStyle.java
+++ b/runelite-client/src/main/java/net/runelite/client/util/ImageUploadStyle.java
@@ -25,6 +25,7 @@
 
 package net.runelite.client.util;
 
+@Deprecated
 public enum ImageUploadStyle
 {
 	NEITHER,
diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/gpu/regions/regions.txt b/runelite-client/src/main/resources/net/runelite/client/plugins/gpu/regions/regions.txt
new file mode 100644
index 000000000..414dec60e
--- /dev/null
+++ b/runelite-client/src/main/resources/net/runelite/client/plugins/gpu/regions/regions.txt
@@ -0,0 +1,236 @@
+// Copyright (c) 2023, Adam <Adam@sigterm.info>
+//
+// All rights reserved.
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are met:
+//
+// 1. Redistributions of source code must retain the above copyright notice, this
+//    list of conditions and the following disclaimer.
+// 2. Redistributions in binary form must reproduce the above copyright notice,
+//    this list of conditions and the following disclaimer in the documentation
+//    and/or other materials provided with the distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+// ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+// Regions format
+//
+// Comments are c-style; Starts with // and ends with the next newline
+//
+//      n
+// Starts a new area. The proceeding r/R/c/C commands then assign chunks to
+// this area.
+//
+// 		m <region X> <region Y>
+// Moves the cursor to the specified region. This does not change any chunks
+//
+// The following operations commit chunks to the map:
+//
+// 		r <region X> <region Y>
+// Sets all the chunks in a region to the current area
+//
+// 		R <region X 1> <region Y 1> <region X 2> <region Y 2>
+// Sets all the chunks in [x1,x2] [y1,y2] to the current area.
+//
+// 		c <chunk X> <chunk Y>
+// Sets the chunk relative to the last region/region1 to the current area
+//
+// 		C <chunk X 1> <chunk Y 1> <chunk X 2> <chunk Y 2>
+// Sets the chunks relative to the last region/region1 to the current area
+
+// dks
+n
+r 45 68
+
+n
+r 45 69
+
+// agility pyramid
+n
+r 47 73
+
+// trawler
+n
+r 31 75
+
+n
+r 30 75
+
+n
+r 29 75
+
+n
+r 28 75
+
+n
+r 28 74
+
+n
+r 31 76
+
+// cosmic entity plane
+n
+r 32 75
+
+// cosmic altar
+n
+r 33 75
+
+// death altar
+n
+r 34 75
+
+// chaos altar
+n
+r 35 75
+
+n
+r 36 75
+
+// air altar
+n
+r 37 75
+
+// law altar
+n
+r 38 75
+
+// body altar
+n
+m 39 75
+C 0 1 6 7
+
+// fire altar
+n
+m 39 75
+C 7 1 7 7
+r 40 75
+
+// earth altar
+n
+r 41 75
+
+// water altar
+n
+r 42 75
+
+// mind altar
+n
+r 43 75
+
+// nature altar
+n
+r 44 75
+
+// essence mine
+n
+r 45 75
+
+// drill demon random
+n
+r 49 75
+
+// blood altar
+n
+r 50 75
+
+// regicide cutscene
+n
+r 36 71
+
+// zanaris
+n
+R 36 68 38 69
+
+// watchtower
+n
+r 45 73
+
+// underground pass
+n
+R 33 71 33 73
+
+// underground pass Iban's temple
+n
+r 31 73
+
+// mime random
+n
+r 31 74
+
+// keldagrim rat pits
+n
+r 30 73
+
+// phesant random
+n
+r 40 74
+
+// crash site
+n
+R 30 86 31 87
+
+// demonic gorillas
+n
+R 32 88 33 88
+
+// sourhog lair
+n
+r 49 151
+
+// draynor manor basement
+n
+r 48 152
+
+// ham hideout
+n
+r 49 150
+
+// draynor sewer
+n
+R 48 150 48 151
+
+// mage bank
+n
+r 39 73
+
+// bob fishing random
+n
+r 39 74
+
+// frog random
+n
+r 38 74
+
+// grand tree basement
+n
+r 38 154
+
+// lighthouse basement
+n
+r 39 156
+
+// haunted mine
+n
+R 42 69 43 71
+
+// mm hangar
+n
+r 41 70
+
+// tree gnome village underground
+n
+r 40 69
+
+// maze random
+n
+r 45 71
\ No newline at end of file
diff --git a/runelite-client/src/main/resources/net/runelite/client/runelite.properties b/runelite-client/src/main/resources/net/runelite/client/runelite.properties
index 7b1778801..9ceee5272 100644
--- a/runelite-client/src/main/resources/net/runelite/client/runelite.properties
+++ b/runelite-client/src/main/resources/net/runelite/client/runelite.properties
@@ -15,7 +15,6 @@ runelite.jav_config=https://oldschool.runescape.com/jav_config.ws
 runelite.jav_config_backup=https://static.runelite.net/jav_config.ws
 runelite.pluginhub.url=https://repo.runelite.net/plugins
 runelite.pluginhub.version=${project.version}
-runelite.imgur.client.id=30d71e5f6860809
 runelite.api.base=https://api.runelite.net/runelite-${project.version}
 runelite.session=https://api.runelite.net/session
 runelite.static.base=https://static.runelite.net


Test patch:
diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/gpu/regions/RegionsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/gpu/regions/RegionsTest.java
new file mode 100644
index 000000000..d2511bf83
--- /dev/null
+++ b/runelite-client/src/test/java/net/runelite/client/plugins/gpu/regions/RegionsTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (c) 2023, Adam <Adam@sigterm.info>
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice, this
+ *    list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ *    this list of conditions and the following disclaimer in the documentation
+ *    and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+ * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package net.runelite.client.plugins.gpu.regions;
+
+import java.io.IOException;
+import java.io.InputStream;
+import lombok.extern.slf4j.Slf4j;
+import static org.junit.Assert.*;
+import org.junit.Test;
+
+@Slf4j
+public class RegionsTest
+{
+	@Test
+	public void test() throws IOException
+	{
+		long start;
+		Regions regions;
+		try (InputStream in = Regions.class.getResourceAsStream("regions.txt"))
+		{
+			start = System.nanoTime();
+			regions = new Regions(in, "regions.txt");
+		}
+		log.info("Parse took {}ms", (System.nanoTime() - start) / 1_000_000);
+
+		// check areas don't intersect
+		for (Region r1 : regions.getRegions())
+		{
+			for (Region r2 : regions.getRegions())
+			{
+				if (r1 == r2)
+				{
+					continue;
+				}
+				if (intersects(r1, r2))
+				{
+					fail("regions intersect");
+				}
+			}
+		}
+
+		assertEquals(0, regions.getRegionId(50 * 8, 50 * 8)); // Lumbridge
+		assertNotEquals(0, regions.getRegionId(2416 / 8, 4448 / 8)); // Zanaris
+	}
+
+	private static boolean intersects(Region r1, Region r2)
+	{
+		return r1.cx1 <= r2.cx2 && r1.cx2 >= r2.cx1 &&
+			r1.cy1 <= r2.cy2 && r1.cy2 >= r2.cy1;
+	}
+}
\ No newline at end of file
diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/itemcharges/ItemChargePluginTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/itemcharges/ItemChargePluginTest.java
index 20a1a0554..bd818b476 100644
--- a/runelite-client/src/test/java/net/runelite/client/plugins/itemcharges/ItemChargePluginTest.java
+++ b/runelite-client/src/test/java/net/runelite/client/plugins/itemcharges/ItemChargePluginTest.java
@@ -44,6 +44,7 @@ import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.Mock;
+import org.mockito.Mockito;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
@@ -99,6 +100,9 @@ public class ItemChargePluginTest
 	private static final String CHECK_BLOOD_ESSENCE = "Your blood essence has 56 charges remaining";
 	private static final String CHECK_BRACELET_OF_CLAY = "You can mine 13 more pieces of soft clay before your bracelet crumbles to dust.";
 	private static final String USED_BRACELET_OF_CLAY = "You manage to mine some clay.";
+
+	private static final String USED_BRACELET_OF_CLAY_TRAHAEARN = "You manage to mine some soft clay.";
+
 	private static final String BREAK_BRACELET_OF_CLAY = "Your bracelet of clay crumbles to dust.";
 
 	@Mock
@@ -503,4 +507,51 @@ public class ItemChargePluginTest
 		itemChargePlugin.onChatMessage(chatMessage);
 		verify(configManager).setRSProfileConfiguration(ItemChargeConfig.GROUP, ItemChargeConfig.KEY_BRACELET_OF_CLAY, 28);
 	}
+	@Test
+	public void testBraceletOfClayUseTrahaearn()
+	{
+		// Set bracelet of clay charges to 13
+		when(configManager.getRSProfileConfiguration(ItemChargeConfig.GROUP, ItemChargeConfig.KEY_BRACELET_OF_CLAY, Integer.class))
+			.thenReturn(13);
+
+		// Equip bracelet of clay
+		ItemContainer equipmentItemContainer = mock(ItemContainer.class);
+		when(client.getItemContainer(InventoryID.EQUIPMENT))
+			.thenReturn(equipmentItemContainer);
+		when(equipmentItemContainer.contains(ItemID.BRACELET_OF_CLAY)).thenReturn(true);
+		when(equipmentItemContainer.getItems()).thenReturn(new Item[0]);
+
+		// Set inventory to 2 free slots
+		ItemContainer inventoryItemContainer = mock(ItemContainer.class);
+		when(inventoryItemContainer.count()).thenReturn(26);
+		when(client.getItemContainer(InventoryID.INVENTORY)).thenReturn(inventoryItemContainer);
+
+		// Verify bracelet of clay charges decreased
+		ChatMessage chatMessage = new ChatMessage(null, ChatMessageType.GAMEMESSAGE, "", USED_BRACELET_OF_CLAY_TRAHAEARN, "", 0);
+		itemChargePlugin.onChatMessage(chatMessage);
+		verify(configManager).setRSProfileConfiguration(ItemChargeConfig.GROUP, ItemChargeConfig.KEY_BRACELET_OF_CLAY, 12);
+	}
+
+	@Test
+	public void testBraceletOfClayUseTrahaearn1FreeInvSlot()
+	{
+		// Equip bracelet of clay
+		ItemContainer equipmentItemContainer = mock(ItemContainer.class);
+		when(client.getItemContainer(InventoryID.EQUIPMENT))
+			.thenReturn(equipmentItemContainer);
+		when(equipmentItemContainer.contains(ItemID.BRACELET_OF_CLAY))
+			.thenReturn(true);
+
+		// Set inventory to 1 free slots
+		ItemContainer inventoryItemContainer = mock(ItemContainer.class);
+		when(inventoryItemContainer.count())
+			.thenReturn(27);
+		when(client.getItemContainer(InventoryID.INVENTORY))
+			.thenReturn(inventoryItemContainer);
+
+		// Verify bracelet of clay charges were not changed
+		ChatMessage chatMessage = new ChatMessage(null, ChatMessageType.GAMEMESSAGE, "", USED_BRACELET_OF_CLAY_TRAHAEARN, "", 0);
+		itemChargePlugin.onChatMessage(chatMessage);
+		verify(configManager, Mockito.times(0)).setRSProfileConfiguration(Mockito.anyString(), Mockito.anyString(), Mockito.anyInt());
+	}
 }