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 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 textureCache = new ConcurrentHashMap<>(); // Pending downloads: URL -> future (to avoid duplicate downloads) private final Map< String, CompletableFuture > pendingDownloads = new ConcurrentHashMap<>(); // Failed URLs: URL -> timestamp of failure (to avoid retry spam) private final Map failedUrls = new ConcurrentHashMap<>(); // Registered textures with Minecraft's texture manager private final Map 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 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() ); } }