skins: only textures.minecraft.net texture urls are fetched (https, no redirects, 128 KiB cap, png header must be 64x64 or 64x32 before decoding), processed exactly like vanilla processLegacySkin (left arm and leg mirrored per face, notch transparency hack, opaque base layers), one download per url outside any map lock, failures retried with backoff instead of cached forever, lru of 512 skins
This commit is contained in:
1 parent
5d581dc482
commit
e08bfc81f3
1 file changed
+205
-40
@@ -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.
|
||||
*
|
||||
* <p>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<String> {
|
||||
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<String, int[][]> skins = new LinkedHashMap<>(64, 0.75f, true) {
|
||||
@Override
|
||||
protected boolean removeEldestEntry(Map.Entry<String, int[][]> eldest) {
|
||||
return size() > CAPACITY;
|
||||
}
|
||||
};
|
||||
private final Map<String, Failure> failures = new LinkedHashMap<>();
|
||||
private final Map<String, CompletableFuture<int[][]>> downloads = new ConcurrentHashMap<>();
|
||||
|
||||
/** The skin at {@code url}, or empty (no/disallowed URL, download failed or backing off, invalid image). */
|
||||
public Optional<int[][]> 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<int[][]> mine = new CompletableFuture<>();
|
||||
CompletableFuture<int[][]> 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<Failure> 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/<hash>}, 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<ImageReader> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user