package ar.com.companeros.horror.client; import ar.com.companeros.Companeros; import ar.com.companeros.horror.ParasiteEntity; import ar.com.companeros.identity.HostSkinAtlas; import ar.com.companeros.identity.SkinHttp; import ar.com.companeros.identity.SkinSources; import com.mojang.blaze3d.platform.NativeImage; import java.net.URI; import java.util.*; import java.util.concurrent.*; import net.minecraft.client.Minecraft; import net.minecraft.client.renderer.texture.DynamicTexture; import net.minecraft.client.resources.DefaultPlayerSkin; import net.minecraft.resources.ResourceLocation; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.ClientPlayerNetworkEvent; import net.minecraftforge.eventbus.api.SubscribeEvent; import net.minecraftforge.fml.common.Mod; /** Piel del huésped, compuesta por etapa y guardada en caché; ninguna descarga ocurre en render(). */ @Mod.EventBusSubscriber(modid = Companeros.ID, value = Dist.CLIENT) public final class ParasiteHostSkin { private static final ResourceLocation STEVE = new ResourceLocation("minecraft", "textures/entity/player/wide/steve.png"); private static final Map SOURCES = new LinkedHashMap<>(); private static final Map ATLASES = new LinkedHashMap<>(); private static final Set PENDING = new HashSet<>(); private static final Map RETRY_AT = new HashMap<>(); private static final ThreadPoolExecutor DOWNLOADS = new ThreadPoolExecutor(1, 1, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(4), task -> { Thread thread = new Thread(task, "raps-host-skin"); thread.setDaemon(true); return thread; }); private static long generation; private record Pixels(int[] argb, int height) { } private ParasiteHostSkin() { } public static ResourceLocation texture(ParasiteEntity body) { Minecraft minecraft = Minecraft.getInstance(); UUID host = body.hostId().orElse(body.getUUID()); String selected = body.getHostSkinSource(); boolean steve = selected.isEmpty() || selected.equalsIgnoreCase("steve") || selected.equalsIgnoreCase("default"); if (!steve && selected.matches("[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}")) { host = UUID.fromString(selected); selected = ""; } else if (steve) selected = ""; else try { selected = SkinSources.texture(selected); } catch (IllegalArgumentException invalid) { selected = ""; steve = true; } String fallback = steve ? "builtin:steve" : "default:" + host; String source = selected.isEmpty() ? fallback : selected; Pixels pixels = SOURCES.get(source); if (pixels == null && !selected.isEmpty()) { request(selected); source = fallback; } pixels = SOURCES.get(source); if (pixels == null) { pixels = defaultSkin(host, steve); SOURCES.put(source, pixels); trimSources(); } String key = source + ":" + body.getMutationStage(); ResourceLocation location = ATLASES.get(key); if (location != null) return location; int[] composed = HostSkinAtlas.compose(pixels.argb(), pixels.height(), body.getMutationStage()); NativeImage image = new NativeImage(128, 128, false); for (int y = 0; y < 128; y++) for (int x = 0; x < 128; x++) image.setPixelRGBA(x, y, reverseRedBlue(composed[y * 128 + x])); location = minecraft.getTextureManager().register("raps_host_skin", new DynamicTexture(image)); ATLASES.put(key, location); while (ATLASES.size() > 64) { String oldest = ATLASES.keySet().iterator().next(); minecraft.getTextureManager().release(ATLASES.remove(oldest)); } return location; } // Vanilla elige su skin predeterminada por UUID, incluso cuando nunca hubo acceso a Luna. private static Pixels defaultSkin(UUID host, boolean steve) { ResourceLocation resource = steve ? STEVE : DefaultPlayerSkin.getDefaultSkin(host); try (var input = Minecraft.getInstance().getResourceManager().getResourceOrThrow(resource).open(); NativeImage skin = NativeImage.read(input)) { return pixels(skin); } catch (java.io.IOException unavailable) { // Un pack que elimina assets vanilla no bloquea el renderizado. int[] safe = new int[64 * 64]; Arrays.fill(safe, 0xFFC58E73); return new Pixels(safe, 64); } } private static Pixels pixels(NativeImage image) { if (image.getWidth() != 64 || (image.getHeight() != 32 && image.getHeight() != 64)) throw new IllegalArgumentException("Skin fuera de dimensiones"); int[] values = new int[64 * image.getHeight()]; for (int y = 0; y < image.getHeight(); y++) for (int x = 0; x < 64; x++) values[y * 64 + x] = reverseRedBlue(image.getPixelRGBA(x, y)); return new Pixels(values, image.getHeight()); } private static int reverseRedBlue(int rgba) { return (rgba & 0xFF00FF00) | ((rgba & 255) << 16) | ((rgba >>> 16) & 255); } // URL enviada por el servidor, revalidada en cliente, respuesta limitada y PNG verificado antes de decodificar. private static void request(String source) { if (PENDING.contains(source) || RETRY_AT.getOrDefault(source, 0L) > System.currentTimeMillis()) return; PENDING.add(source); long requestedGeneration = generation; try { DOWNLOADS.execute(() -> { Pixels loaded = null; try { byte[] png = SkinHttp.get(URI.create(SkinSources.texture(source)), 131_072); if (!SkinSources.playerPng(png)) throw new IllegalArgumentException("PNG de skin inválido"); try (NativeImage image = NativeImage.read(png)) { loaded = pixels(image); } } catch (java.io.IOException | InterruptedException | RuntimeException unavailable) { } Pixels completed = loaded; Minecraft.getInstance().execute(() -> { if (generation != requestedGeneration) return; PENDING.remove(source); if (completed == null) RETRY_AT.put(source, System.currentTimeMillis() + 300_000); else { SOURCES.put(source, completed); trimSources(); RETRY_AT.remove(source); } }); }); } catch (RejectedExecutionException full) { PENDING.remove(source); RETRY_AT.put(source, System.currentTimeMillis() + 60_000); } } private static void trimSources() { while (SOURCES.size() > 16) SOURCES.remove(SOURCES.keySet().iterator().next()); } @SubscribeEvent public static void logout(ClientPlayerNetworkEvent.LoggingOut event) { generation++; PENDING.clear(); RETRY_AT.clear(); SOURCES.clear(); ATLASES.values().forEach(location -> Minecraft.getInstance().getTextureManager().release(location)); ATLASES.clear(); } }