Files
TiedUp-/src/main/java/com/tiedup/remake/client/texture/DynamicTextureManager.java
NotEvil a71093ba9c Remove internal phase comments and format code
Strip all Phase references, TODO/FUTURE roadmap notes, and internal
planning comments from the codebase. Run Prettier for consistent
formatting across all Java files.
2026-04-12 01:25:55 +02:00

508 lines
17 KiB
Java

package com.tiedup.remake.client.texture;
import com.mojang.blaze3d.platform.NativeImage;
import com.tiedup.remake.core.ModConfig;
import com.tiedup.remake.core.TiedUpMod;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import javax.imageio.ImageIO;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.ResourceLocation;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.api.distmarker.OnlyIn;
import org.jetbrains.annotations.Nullable;
/**
* Manager for dynamically loaded online textures.
*
* Features:
* - Async HTTP download with thread pool
* - Host whitelist validation
* - Memory cache with LRU-style expiration
* - Failed URL tracking to prevent retry spam
* - Thread-safe operations
*
* Singleton pattern - access via getInstance().
*/
@OnlyIn(Dist.CLIENT)
public class DynamicTextureManager {
private static DynamicTextureManager INSTANCE;
// Configuration
private static final int CONNECT_TIMEOUT_MS = 10_000; // 10 seconds
private static final int READ_TIMEOUT_MS = 30_000; // 30 seconds
private static final int MAX_CACHE_SIZE = 50;
private static final long FAILED_URL_RETRY_MS = 5 * 60 * 1000; // 5 minutes
// Default whitelist of allowed hosts
private static final Set<String> DEFAULT_WHITELIST = Set.of(
"i.imgur.com",
"cdn.discordapp.com",
"media.discordapp.net",
"raw.githubusercontent.com",
"user-images.githubusercontent.com",
"64.media.tumblr.com",
"www.minecraftskins.com",
"minecraftskins.com",
"textures.minecraft.net",
"crafatar.com",
"mc-heads.net",
"minotar.net",
"novaskin.me",
"t.novaskin.me",
"s.namemc.com",
"namemc.com"
);
// Thread pool for async downloads
private final Executor downloadExecutor = Executors.newFixedThreadPool(
2,
r -> {
Thread t = new Thread(r, "TiedUp-TextureDownloader");
t.setDaemon(true);
return t;
}
);
// Cache: URL -> loaded texture
private final Map<String, DynamicOnlineTexture> textureCache =
new ConcurrentHashMap<>();
// Pending downloads: URL -> future (to avoid duplicate downloads)
private final Map<
String,
CompletableFuture<DynamicOnlineTexture>
> pendingDownloads = new ConcurrentHashMap<>();
// Failed URLs: URL -> timestamp of failure (to avoid retry spam)
private final Map<String, Long> failedUrls = new ConcurrentHashMap<>();
// Registered textures with Minecraft's texture manager
private final Map<String, ResourceLocation> registeredTextures =
new ConcurrentHashMap<>();
private DynamicTextureManager() {}
/**
* Get the singleton instance.
*/
public static DynamicTextureManager getInstance() {
if (INSTANCE == null) {
INSTANCE = new DynamicTextureManager();
}
return INSTANCE;
}
/**
* Get a texture for a URL, loading it asynchronously if needed.
*
* @param url The texture URL
* @return The texture if cached, or null if loading/failed
*/
@Nullable
public DynamicOnlineTexture getTexture(String url) {
if (url == null || url.isEmpty()) return null;
// Check config
if (!ModConfig.CLIENT.enableDynamicTextures.get()) {
return null;
}
// Check cache
DynamicOnlineTexture cached = textureCache.get(url);
if (cached != null) {
return cached;
}
// Check if failed recently
Long failTime = failedUrls.get(url);
if (
failTime != null &&
System.currentTimeMillis() - failTime < FAILED_URL_RETRY_MS
) {
return null;
}
// Start async download if not already pending
if (!pendingDownloads.containsKey(url)) {
downloadAsync(url);
}
return null;
}
/**
* Check if a texture is loaded and ready.
*/
public boolean isTextureReady(String url) {
if (url == null || url.isEmpty()) return false;
DynamicOnlineTexture tex = textureCache.get(url);
return tex != null && tex.isValid();
}
/**
* Start an async download for a URL.
*/
private void downloadAsync(String url) {
CompletableFuture<DynamicOnlineTexture> future =
CompletableFuture.supplyAsync(
() -> {
try {
return downloadTexture(url);
} catch (Exception e) {
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] Download failed for {}: {}",
url,
e.getMessage()
);
return null;
}
},
downloadExecutor
);
pendingDownloads.put(url, future);
future.thenAccept(texture -> {
pendingDownloads.remove(url);
if (texture != null && texture.isValid()) {
// Cache management - remove oldest if at capacity
if (textureCache.size() >= MAX_CACHE_SIZE) {
evictOldest();
}
textureCache.put(url, texture);
TiedUpMod.LOGGER.info(
"[DynamicTextureManager] Loaded texture from {}",
url
);
} else {
failedUrls.put(url, System.currentTimeMillis());
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] Failed to load texture from {}",
url
);
}
});
}
/**
* Download a texture from a URL.
* Validates host whitelist and performs the HTTP download.
*/
@Nullable
private DynamicOnlineTexture downloadTexture(String urlString) {
try {
// Validate URL format
if (!urlString.startsWith("https://")) {
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] Rejected non-HTTPS URL: {}",
urlString
);
return null;
}
URL url = URI.create(urlString).toURL();
String host = url.getHost().toLowerCase();
// Validate host whitelist
if (ModConfig.CLIENT.useTextureHostWhitelist.get()) {
if (!isHostWhitelisted(host)) {
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] Host not whitelisted: {}",
host
);
return null;
}
}
// Open connection with browser-like headers
// Many CDNs (including Tumblr) block requests without proper headers
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setRequestProperty(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
);
conn.setRequestProperty(
"Accept",
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
);
conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9");
conn.setInstanceFollowRedirects(true);
int responseCode = conn.getResponseCode();
if (responseCode != 200) {
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] HTTP {} for {}",
responseCode,
urlString
);
return null;
}
String contentType = conn.getContentType();
TiedUpMod.LOGGER.debug(
"[DynamicTextureManager] Content-Type: {} for {}",
contentType,
urlString
);
// Read image - convert any format to PNG for NativeImage compatibility
try (InputStream is = conn.getInputStream()) {
// Buffer the stream so we can inspect/retry if needed
byte[] imageBytes = is.readAllBytes();
TiedUpMod.LOGGER.info(
"[DynamicTextureManager] Downloaded {} bytes (Content-Type: {}) from {}",
imageBytes.length,
contentType,
urlString
);
if (imageBytes.length == 0) {
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] Empty response from {}",
urlString
);
return null;
}
// Check if response looks like HTML (error page) instead of image
if (imageBytes.length > 0 && imageBytes[0] == '<') {
String preview = new String(
imageBytes,
0,
Math.min(200, imageBytes.length)
);
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] Response looks like HTML, not an image: {}",
preview
);
return null;
}
NativeImage image = readImageAsNative(
new ByteArrayInputStream(imageBytes),
urlString
);
if (image == null) {
// Log first bytes to help debug
StringBuilder hexPreview = new StringBuilder();
for (int i = 0; i < Math.min(16, imageBytes.length); i++) {
hexPreview.append(
String.format("%02X ", imageBytes[i])
);
}
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] First 16 bytes: {}",
hexPreview
);
return null;
}
return new DynamicOnlineTexture(image, urlString);
}
} catch (Exception e) {
TiedUpMod.LOGGER.error(
"[DynamicTextureManager] Error downloading {}: {}",
urlString,
e.getMessage()
);
return null;
}
}
/**
* Read an image from InputStream, converting any format (JPG, PNG, etc.) to NativeImage.
* NativeImage only supports PNG natively, so we use ImageIO to read other formats
* and convert them to PNG bytes.
*
* @param is The input stream containing image data
* @param urlString The URL (for logging)
* @return NativeImage or null if failed
*/
@Nullable
private NativeImage readImageAsNative(InputStream is, String urlString) {
try {
// First, try reading with ImageIO (supports JPG, PNG, GIF, BMP, etc.)
BufferedImage bufferedImage = ImageIO.read(is);
if (bufferedImage == null) {
// Try to peek at the first bytes to see what we got
TiedUpMod.LOGGER.warn(
"[DynamicTextureManager] ImageIO could not read image from {} - format may be unsupported or data is not an image",
urlString
);
return null;
}
TiedUpMod.LOGGER.debug(
"[DynamicTextureManager] Read image {}x{} from {}",
bufferedImage.getWidth(),
bufferedImage.getHeight(),
urlString
);
// Convert BufferedImage to PNG bytes, then to NativeImage
ByteArrayOutputStream pngOutput = new ByteArrayOutputStream();
ImageIO.write(bufferedImage, "PNG", pngOutput);
byte[] pngBytes = pngOutput.toByteArray();
TiedUpMod.LOGGER.debug(
"[DynamicTextureManager] Converted to {} PNG bytes",
pngBytes.length
);
// Now read as NativeImage (PNG format is supported)
try (
ByteArrayInputStream pngInput = new ByteArrayInputStream(
pngBytes
)
) {
return NativeImage.read(pngInput);
}
} catch (Exception e) {
TiedUpMod.LOGGER.error(
"[DynamicTextureManager] Failed to convert image from {}: {}",
urlString,
e.getMessage(),
e
);
return null;
}
}
/**
* Check if a host is in the whitelist.
*/
private boolean isHostWhitelisted(String host) {
// Check default whitelist
if (DEFAULT_WHITELIST.contains(host)) {
return true;
}
// Check config additional whitelist
for (String allowed : ModConfig.CLIENT.textureHostWhitelist.get()) {
if (
host.equals(allowed.toLowerCase()) ||
host.endsWith("." + allowed.toLowerCase())
) {
return true;
}
}
return false;
}
/**
* Evict the oldest texture from cache.
*/
private void evictOldest() {
// Simple eviction: remove first entry
var iterator = textureCache.entrySet().iterator();
if (iterator.hasNext()) {
var entry = iterator.next();
entry.getValue().close();
iterator.remove();
registeredTextures.remove(entry.getKey());
}
}
/**
* Get or create a ResourceLocation for a dynamic texture.
* Registers the texture with Minecraft's texture manager.
*
* @param url The texture URL
* @param fullSkin Whether to use full-skin mode texture
* @return The ResourceLocation, or null if not available
*/
@Nullable
public ResourceLocation getTextureLocation(String url, boolean fullSkin) {
DynamicOnlineTexture texture = getTexture(url);
if (texture == null || !texture.isValid()) return null;
String key = url + (fullSkin ? "_fullskin" : "_clothes");
return registeredTextures.computeIfAbsent(key, k -> {
var dynTex = fullSkin
? texture.getFullSkinTexture()
: texture.getClothesTexture();
if (dynTex == null) return null;
// Create unique ResourceLocation
int hash = Math.abs(key.hashCode());
ResourceLocation loc = ResourceLocation.fromNamespaceAndPath(
TiedUpMod.MOD_ID,
"dynamic/clothes_" + hash
);
// Register with Minecraft
Minecraft.getInstance().getTextureManager().register(loc, dynTex);
return loc;
});
}
/**
* Invalidate a specific URL from cache.
*/
public void invalidate(String url) {
DynamicOnlineTexture tex = textureCache.remove(url);
if (tex != null) {
tex.close();
}
pendingDownloads.remove(url);
failedUrls.remove(url);
registeredTextures.remove(url + "_clothes");
registeredTextures.remove(url + "_fullskin");
}
/**
* Clear all caches. Called on world unload.
*/
public void clearAll() {
for (DynamicOnlineTexture tex : textureCache.values()) {
tex.close();
}
textureCache.clear();
pendingDownloads.clear();
failedUrls.clear();
registeredTextures.clear();
TiedUpMod.LOGGER.info(
"[DynamicTextureManager] Cleared all texture caches"
);
}
/**
* Clean up stale failed URL entries.
*/
public void cleanupStale() {
long now = System.currentTimeMillis();
failedUrls
.entrySet()
.removeIf(e -> now - e.getValue() > FAILED_URL_RETRY_MS * 2);
}
/**
* Get cache statistics for debugging.
*/
public String getCacheStats() {
return String.format(
"Cache: %d textures, %d pending, %d failed",
textureCache.size(),
pendingDownloads.size(),
failedUrls.size()
);
}
}