Strip all Phase references, TODO/FUTURE roadmap notes, and internal planning comments from the codebase. Run Prettier for consistent formatting across all Java files.
508 lines
17 KiB
Java
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()
|
|
);
|
|
}
|
|
}
|