diff --git a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java
index 5a9a450..1847189 100644
--- a/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java
+++ b/src/main/java/eu/mhsl/minecraft/pixelpics/assets/SkinCache.java
@@ -1,68 +1,233 @@
package eu.mhsl.minecraft.pixelpics.assets;
import java.awt.image.BufferedImage;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
import java.io.InputStream;
+import java.net.HttpURLConnection;
import java.net.URI;
-import java.net.URL;
-import java.net.URLConnection;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
+import javax.imageio.ImageReader;
+import javax.imageio.stream.ImageInputStream;
/**
- * Player skins by URL as 64x64 ARGB grids (legacy 64x32 skins converted). Downloaded off the main thread, from
+ * Player skins by profile texture URL as 64x64 ARGB grids, processed like vanilla's
+ * {@code SkinTextureDownloader.processLegacySkin} (legacy 64x32 skins converted). Downloaded off the main thread, from
* the baking step.
+ *
+ *
Profile textures of heads and NPCs are not signed, so the URL is untrusted: only Mojang's texture server is
+ * contacted ({@code http(s)://textures.minecraft.net/texture/…}, fetched over https without redirects), the
+ * download is capped at {@link #MAX_BYTES} and the PNG header must declare 64x64 or 64x32 (the only sizes vanilla
+ * accepts) before anything is decoded. Concurrent requests for one URL share a single download; other URLs are not
+ * blocked meanwhile. Failed downloads are retried after a growing backoff. The cache keeps the {@link #CAPACITY}
+ * most recently used skins.
*/
-public final class SkinCache extends ImageCache {
+public final class SkinCache {
- @Override
+ static final String HOST = "textures.minecraft.net";
+ static final int MAX_BYTES = 128 * 1024;
+ private static final int CAPACITY = 512;
+ private static final int TIMEOUT_MILLIS = 5000;
+ private static final long FIRST_BACKOFF_MILLIS = 60_000;
+ private static final long MAX_BACKOFF_MILLIS = 60 * 60_000;
+
+ private record Failure(long retryAt, long backoff) {}
+
+ private final Map skins = new LinkedHashMap<>(64, 0.75f, true) {
+ @Override
+ protected boolean removeEldestEntry(Map.Entry eldest) {
+ return size() > CAPACITY;
+ }
+ };
+ private final Map failures = new LinkedHashMap<>();
+ private final Map> downloads = new ConcurrentHashMap<>();
+
+ /** The skin at {@code url}, or empty (no/disallowed URL, download failed or backing off, invalid image). */
public Optional get(String url) {
- if (url == null || url.isEmpty()) return Optional.empty();
- return super.get(url);
+ String fetch = httpsUrl(url);
+ if (fetch == null) return Optional.empty();
+ synchronized (this) {
+ int[][] cached = skins.get(fetch);
+ if (cached != null) return Optional.of(cached);
+ Failure failed = failures.get(fetch);
+ if (failed != null && System.currentTimeMillis() < failed.retryAt()) return Optional.empty();
+ }
+ CompletableFuture mine = new CompletableFuture<>();
+ CompletableFuture running = downloads.putIfAbsent(fetch, mine);
+ if (running != null) return Optional.ofNullable(running.join());
+
+ int[][] skin = null;
+ try {
+ synchronized (this) {
+ skin = skins.get(fetch); // finished between the cache check and putIfAbsent
+ }
+ if (skin == null) skin = load(fetch);
+ } finally {
+ synchronized (this) {
+ if (skin != null) {
+ skins.put(fetch, skin);
+ failures.remove(fetch);
+ } else {
+ Failure before = failures.get(fetch);
+ long backoff =
+ before == null ? FIRST_BACKOFF_MILLIS : Math.min(MAX_BACKOFF_MILLIS, before.backoff() * 2);
+ failures.put(fetch, new Failure(System.currentTimeMillis() + backoff, backoff));
+ pruneFailures();
+ }
+ }
+ downloads.remove(fetch);
+ mine.complete(skin);
+ }
+ return Optional.ofNullable(skin);
}
- @Override
- protected int[][] load(String url) {
+ /** Drops failures whose backoff ran out, and the oldest ones beyond the capacity. */
+ private void pruneFailures() {
+ long now = System.currentTimeMillis();
+ Iterator it = failures.values().iterator();
+ while (it.hasNext()) {
+ if (it.next().retryAt() <= now || failures.size() > CAPACITY) it.remove();
+ }
+ }
+
+ /**
+ * The https form of a texture URL on {@link #HOST} ({@code /texture/}, Mojang profiles use http), or null
+ * for any other URL.
+ */
+ static String httpsUrl(String url) {
+ if (url == null || url.isEmpty()) return null;
try {
- URL u = URI.create(url).toURL();
- URLConnection conn = u.openConnection();
- conn.setConnectTimeout(5000);
- conn.setReadTimeout(5000);
- conn.setRequestProperty("User-Agent", "PixelPics");
- BufferedImage img;
- try (InputStream in = conn.getInputStream()) {
- img = ImageIO.read(in);
- }
- if (img == null) return null;
- return toModern(img);
+ URI u = new URI(url);
+ String scheme = u.getScheme() == null ? "" : u.getScheme().toLowerCase(Locale.ROOT);
+ if (!scheme.equals("http") && !scheme.equals("https")) return null;
+ if (u.getRawUserInfo() != null || u.getPort() != -1 || u.getRawQuery() != null) return null;
+ if (u.getHost() == null || !u.getHost().equalsIgnoreCase(HOST)) return null;
+ String path = u.getRawPath();
+ if (path == null || !path.matches("/texture/[0-9a-zA-Z]+")) return null;
+ return "https://" + HOST + path;
} catch (Exception e) {
return null;
}
}
- /** Reads the image to a 64x64 grid, converting the legacy 64x32 layout (mirrored arm/leg) if needed. */
- private int[][] toModern(BufferedImage img) {
- int w = Math.min(img.getWidth(), 64);
- int h = Math.min(img.getHeight(), 64);
- int[][] out = new int[64][64];
- int[][] src = pixels(img, w, h);
- for (int y = 0; y < h; y++) System.arraycopy(src[y], 0, out[y], 0, w);
- if (h <= 32) {
- // Legacy skin: mirrored copies of the right-arm region (40,16) into the modern left-arm slot (32,48).
- copyMirrored(img, out, 44, 16, 36, 48);
- copyMirrored(img, out, 44, 16, 36, 52);
- copyMirrored(img, out, 40, 16, 32, 48);
+ private static int[][] load(String url) {
+ try {
+ HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
+ try {
+ conn.setInstanceFollowRedirects(false);
+ conn.setConnectTimeout(TIMEOUT_MILLIS);
+ conn.setReadTimeout(TIMEOUT_MILLIS);
+ conn.setRequestProperty("User-Agent", "PixelPics");
+ if (conn.getResponseCode() / 100 != 2) return null;
+ if (conn.getContentLengthLong() > MAX_BYTES) return null;
+ byte[] data;
+ try (InputStream in = conn.getInputStream()) {
+ data = readLimited(in);
+ }
+ return data == null ? null : decode(data);
+ } finally {
+ conn.disconnect();
+ }
+ } catch (IOException | RuntimeException e) {
+ return null;
}
- return out;
}
- /** Mirror-copies a 16x12 block (whole block flipped horizontally, not per face); coarse approximation. */
- private void copyMirrored(BufferedImage img, int[][] out, int srcX, int srcY, int dstX, int dstY) {
- for (int y = 0; y < 12 && srcY + y < img.getHeight(); y++) {
- for (int x = 0; x < 16 && srcX + x < img.getWidth(); x++) {
- int px = img.getRGB(srcX + (15 - x), srcY + y);
- int ox = dstX + x, oy = dstY + y;
- if (ox < 64 && oy < 64) out[oy][ox] = px;
+ /** At most {@link #MAX_BYTES} bytes of {@code in}; null when there are more. */
+ static byte[] readLimited(InputStream in) throws IOException {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int total = 0;
+ for (int n; (n = in.read(buf)) != -1; ) {
+ total += n;
+ if (total > MAX_BYTES) return null;
+ out.write(buf, 0, n);
+ }
+ return out.toByteArray();
+ }
+
+ /** Decodes a PNG skin whose header declares 64x64 or 64x32 (checked before decoding); null otherwise. */
+ static int[][] decode(byte[] png) throws IOException {
+ try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(png))) {
+ Iterator readers = ImageIO.getImageReadersByFormatName("png");
+ if (in == null || !readers.hasNext()) return null;
+ ImageReader reader = readers.next();
+ try {
+ reader.setInput(in, true, true);
+ int w = reader.getWidth(0), h = reader.getHeight(0);
+ if (w != 64 || (h != 64 && h != 32)) return null; // vanilla discards other sizes
+ BufferedImage img = reader.read(0);
+ int[][] argb = new int[h][];
+ for (int y = 0; y < h; y++) argb[y] = img.getRGB(0, y, w, 1, null, 0, w);
+ return processSkin(argb);
+ } finally {
+ reader.dispose();
}
}
}
+
+ /**
+ * SkinTextureDownloader.processLegacySkin (26.3) on a 64x64 or 64x32 ARGB grid: a legacy skin is extended to
+ * 64x64 with the left arm and leg made from the right ones (each face mirrored in place), its hat layer gets the
+ * "Notch transparency hack", and the base layers are made opaque.
+ */
+ static int[][] processSkin(int[][] src) {
+ boolean legacy = src.length == 32;
+ int[][] img = new int[64][64];
+ for (int y = 0; y < src.length; y++) System.arraycopy(src[y], 0, img[y], 0, 64);
+ if (legacy) {
+ copyRect(img, 4, 16, 16, 32, 4, 4);
+ copyRect(img, 8, 16, 16, 32, 4, 4);
+ copyRect(img, 0, 20, 24, 32, 4, 12);
+ copyRect(img, 4, 20, 16, 32, 4, 12);
+ copyRect(img, 8, 20, 8, 32, 4, 12);
+ copyRect(img, 12, 20, 16, 32, 4, 12);
+ copyRect(img, 44, 16, -8, 32, 4, 4);
+ copyRect(img, 48, 16, -8, 32, 4, 4);
+ copyRect(img, 40, 20, 0, 32, 4, 12);
+ copyRect(img, 44, 20, -8, 32, 4, 12);
+ copyRect(img, 48, 20, -16, 32, 4, 12);
+ copyRect(img, 52, 20, -8, 32, 4, 12);
+ }
+ setNoAlpha(img, 0, 0, 32, 16);
+ if (legacy) notchTransparencyHack(img, 32, 0, 64, 32);
+ setNoAlpha(img, 0, 16, 64, 32);
+ setNoAlpha(img, 16, 48, 48, 64);
+ return img;
+ }
+
+ /** NativeImage.copyRect(startX, startY, offsetX, offsetY, sizeX, sizeY, swapX = true, swapY = false). */
+ private static void copyRect(int[][] img, int sx, int sy, int offX, int offY, int w, int h) {
+ for (int y = 0; y < h; y++) {
+ for (int x = 0; x < w; x++) {
+ img[sy + offY + y][sx + offX + (w - 1 - x)] = img[sy + y][sx + x];
+ }
+ }
+ }
+
+ /** Clears the alpha of the whole region only if every texel in it has alpha >= 128 (doNotchTransparencyHack). */
+ private static void notchTransparencyHack(int[][] img, int x0, int y0, int x1, int y1) {
+ for (int y = y0; y < y1; y++) {
+ for (int x = x0; x < x1; x++) {
+ if ((img[y][x] >>> 24) < 128) return;
+ }
+ }
+ for (int y = y0; y < y1; y++) {
+ for (int x = x0; x < x1; x++) img[y][x] &= 0xFFFFFF;
+ }
+ }
+
+ private static void setNoAlpha(int[][] img, int x0, int y0, int x1, int y1) {
+ for (int y = y0; y < y1; y++) {
+ for (int x = x0; x < x1; x++) img[y][x] |= 0xFF000000;
+ }
+ }
}