package com.razz.dfashion.client; import com.mojang.blaze3d.platform.NativeImage; import com.razz.dfashion.DecoFashion; import com.razz.dfashion.skin.SafePngReader; import com.razz.dfashion.skin.SkinCache; import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.packet.UploadSkinChunk; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.ClientPacketListener; import net.minecraft.client.renderer.texture.DynamicTexture; import net.minecraft.client.renderer.texture.TextureManager; import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket; import net.minecraft.resources.Identifier; import net.neoforged.fml.loading.FMLPaths; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.zip.Deflater; /** * Client-side skin store. Holds pixel blobs under * {@code /decofashion/skins_cache/.bin} (4-byte {@code (u16 w, u16 h)} header * + deflated RGBA body), and registers decoded images as {@link DynamicTexture}s under * synthetic {@code decofashion:skins/} identifiers. * *

The only PNG parser in the pipeline is {@link SafePngReader} on the uploading client. * Inbound data from the server is already raw deflated RGBA — we inflate under a bounded * cap, fill a {@link NativeImage} directly via {@code setPixelABGR}, and never invoke STB. */ public final class ClientSkinCache { private static final String SUBDIR = "decofashion/skins_cache"; private static final Map REGISTERED = new ConcurrentHashMap<>(); private static final Map IN_FLIGHT = new ConcurrentHashMap<>(); private static final class Download { int expectedTotal; int nextIndex; int width; int height; ByteArrayOutputStream buffer = new ByteArrayOutputStream(); } private ClientSkinCache() {} private static Path root() { return FMLPaths.GAMEDIR.get().resolve(SUBDIR); } private static Path fileFor(String hash) { return root().resolve(hash + SkinCache.FILE_EXT); } public static Identifier textureIdFor(String hash) { return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash); } public static boolean hasRegistered(String hash) { return REGISTERED.containsKey(hash); } public static boolean hasOnDisk(String hash) { return Files.isRegularFile(fileFor(hash)); } /** Load from disk + register if we have the blob cached but haven't built a texture yet. */ public static Identifier ensureLoadedFromDisk(String hash) { if (!SkinCache.isValidHash(hash)) return null; Identifier existing = REGISTERED.get(hash); if (existing != null) return existing; if (!hasOnDisk(hash)) return null; try { byte[] blob = Files.readAllBytes(fileFor(hash)); int[] dims = SkinCache.readHeader(blob); if (dims == null) { DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash); return null; } int w = dims[0], h = dims[1]; if (w <= 0 || h <= 0 || w > SkinCache.MAX_DIM || h > SkinCache.MAX_DIM) { DecoFashion.LOGGER.error("Skin cache: {} has bad dims {}x{}", hash, w, h); return null; } byte[] deflated = new byte[blob.length - SkinCache.HEADER_SIZE]; System.arraycopy(blob, SkinCache.HEADER_SIZE, deflated, 0, deflated.length); byte[] rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(w, h)); return registerFromPixels(hash, w, h, rgba); } catch (IOException ex) { DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex); return null; } } public static boolean isAwaiting(String hash) { return IN_FLIGHT.containsKey(hash); } public static void markRequested(String hash) { IN_FLIGHT.putIfAbsent(hash, new Download()); } /** * Consume one inbound deflated-pixel chunk. When the last chunk lands, inflates with a * bounded cap, writes the blob to disk, builds a {@link NativeImage} directly from pixels * (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more * chunks are expected or on error. */ public static Identifier onChunk(String hash, int index, int total, int width, int height, byte[] data) { if (width <= 0 || height <= 0 || width > SkinCache.MAX_DIM || height > SkinCache.MAX_DIM) { DecoFashion.LOGGER.warn("Skin download {}: bad dims {}x{}", hash, width, height); IN_FLIGHT.remove(hash); return null; } Download d = IN_FLIGHT.get(hash); if (d == null) { d = new Download(); IN_FLIGHT.put(hash, d); } if (index == 0) { d.expectedTotal = total; d.nextIndex = 0; d.width = width; d.height = height; d.buffer.reset(); } else if (d.expectedTotal != total || d.nextIndex != index || d.width != width || d.height != height) { DecoFashion.LOGGER.warn("Skin download {}: chunk mismatch at {}/{}", hash, index, total); IN_FLIGHT.remove(hash); return null; } if (d.buffer.size() + data.length > SkinCache.MAX_DEFLATED_BYTES) { DecoFashion.LOGGER.warn("Skin download {}: exceeds deflated cap", hash); IN_FLIGHT.remove(hash); return null; } try { d.buffer.write(data); } catch (IOException ex) { DecoFashion.LOGGER.error("Skin download {}: write failed", hash, ex); IN_FLIGHT.remove(hash); return null; } d.nextIndex = index + 1; if (index + 1 < total) return null; IN_FLIGHT.remove(hash); byte[] deflated = d.buffer.toByteArray(); byte[] rgba; try { rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(d.width, d.height)); } catch (IllegalArgumentException ex) { DecoFashion.LOGGER.warn("Skin download {}: {}", hash, ex.getMessage()); return null; } catch (IOException ex) { DecoFashion.LOGGER.warn("Skin download {}: inflate failed ({})", hash, ex.getMessage()); return null; } try { Files.createDirectories(root()); Files.write(fileFor(hash), SkinCache.buildBlob(d.width, d.height, deflated)); } catch (IOException ex) { DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex); } return registerFromPixels(hash, d.width, d.height, rgba); } /** * Build a {@link NativeImage} directly from RGBA bytes and register it. No PNG decoder * is involved — {@code NativeImage} is allocated in RGBA format and each pixel is written * via {@code setPixelABGR}, where the int is packed so the native {@code memPutInt} lays * down bytes in the order {@code [R, G, B, A]} (little-endian platforms). */ private static Identifier registerFromPixels(String hash, int width, int height, byte[] rgba) { int expected; try { expected = SkinCache.rgbaByteCount(width, height); } catch (IllegalArgumentException ex) { DecoFashion.LOGGER.error("Skin {}: {}", hash, ex.getMessage()); return null; } if (rgba.length != expected) { DecoFashion.LOGGER.error("Skin {}: pixel buffer length {} != {}*{}*4", hash, rgba.length, width, height); return null; } NativeImage img = new NativeImage(width, height, false); int src = 0; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { int r = rgba[src++] & 0xFF; int g = rgba[src++] & 0xFF; int b = rgba[src++] & 0xFF; int a = rgba[src++] & 0xFF; img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24)); } } TextureManager tm = Minecraft.getInstance().getTextureManager(); Identifier id = textureIdFor(hash); tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img)); REGISTERED.put(hash, id); DecoFashion.LOGGER.info("Skin registered: hash={} id={} dims={}x{}", hash, id, width, height); return id; } public static Map snapshotRegistered() { return new HashMap<>(REGISTERED); } /** * Register every cached blob under {@code /decofashion/skins_cache/}. Runs on * client setup and wardrobe open so previously-uploaded skins reappear in the grid * without waiting for an attachment to name them. Filename must be a 64-char lowercase * hex hash with {@code .bin} suffix. */ public static void scanDisk() { Path dir = root(); if (!Files.isDirectory(dir)) return; int loaded = 0; int expectedNameLen = 64 + SkinCache.FILE_EXT.length(); try (java.nio.file.DirectoryStream stream = Files.newDirectoryStream(dir, "*" + SkinCache.FILE_EXT)) { for (Path bin : stream) { String name = bin.getFileName().toString(); if (name.length() != expectedNameLen) continue; String hash = name.substring(0, 64); if (!SkinCache.isValidHash(hash)) continue; if (REGISTERED.containsKey(hash)) continue; if (ensureLoadedFromDisk(hash) != null) loaded++; } } catch (IOException ex) { DecoFashion.LOGGER.warn("Skin cache scan failed: {}", ex.getMessage()); } if (loaded > 0) DecoFashion.LOGGER.info("Skin cache: scanned {} skin(s) from disk", loaded); } /** * Drop a cached skin from this client — releases the texture, forgets the hash→id map * entry, and removes the on-disk blob. The server's copy is untouched. */ public static boolean delete(String hash) { if (!SkinCache.isValidHash(hash)) return false; Identifier id = REGISTERED.remove(hash); boolean changed = id != null; if (id != null) { try { Minecraft.getInstance().getTextureManager().release(id); } catch (Throwable t) { DecoFashion.LOGGER.warn("Skin cache: texture release failed for {}: {}", hash, t.getMessage()); } } try { if (Files.deleteIfExists(fileFor(hash))) changed = true; } catch (IOException ex) { DecoFashion.LOGGER.warn("Skin cache: file delete failed for {}: {}", hash, ex.getMessage()); } DecoFashion.LOGGER.info("Skin cache: delete({}) changed={}", hash, changed); return changed; } /** * Read a PNG from disk, decode it with {@link SafePngReader} (pure Java, no STB), * deflate the raw RGBA, chunk it, and ship chunks upstream as {@link UploadSkinChunk}s. * Also caches locally under the same hash the server will compute so the wardrobe shows * it immediately. Returns a short user-facing status string. */ public static String uploadFromFile(Path file, SkinModel model) { String result = doUpload(file, model); DecoFashion.LOGGER.info("Skin upload: {}", result); return result; } private static String doUpload(Path file, SkinModel model) { if (!Files.isRegularFile(file)) return "Not a file: " + file; byte[] png; try { long size = Files.size(file); if (size > SafeImageLoader.MAX_PNG_FILE_BYTES) { return "PNG file too large: " + size; } png = Files.readAllBytes(file); } catch (IOException ex) { return "Read failed: " + ex.getMessage(); } SafePngReader.Image decoded; try { decoded = SafePngReader.decode(png); } catch (IOException ex) { return "Rejected: " + ex.getMessage(); } byte[] deflated = deflate(decoded.rgba); if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) { return "Too large after compression: " + deflated.length; } ClientPacketListener conn = Minecraft.getInstance().getConnection(); if (conn == null) return "Not connected"; String localHash; try { localHash = sha256HexOfPixels(decoded.width, decoded.height, decoded.rgba); } catch (NoSuchAlgorithmException ex) { return "SHA-256 unavailable"; } try { Files.createDirectories(root()); if (!hasOnDisk(localHash)) { Files.write(fileFor(localHash), SkinCache.buildBlob(decoded.width, decoded.height, deflated)); } if (!REGISTERED.containsKey(localHash)) { registerFromPixels(localHash, decoded.width, decoded.height, decoded.rgba); } } catch (IOException ex) { DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage()); } int chunk = SkinCache.MAX_CHUNK_BYTES; int total = Math.max(1, (deflated.length + chunk - 1) / chunk); for (int i = 0; i < total; i++) { int off = i * chunk; int len = Math.min(chunk, deflated.length - off); byte[] slice = new byte[len]; System.arraycopy(deflated, off, slice, 0, len); conn.send(new ServerboundCustomPayloadPacket( new UploadSkinChunk(i, total, decoded.width, decoded.height, model, slice))); } return "Uploading " + decoded.width + "x" + decoded.height + " (" + deflated.length + " bytes, " + total + " chunks, hash " + localHash.substring(0, 8) + ")"; } private static byte[] deflate(byte[] raw) { Deflater def = new Deflater(Deflater.BEST_COMPRESSION); try { def.setInput(raw); def.finish(); ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64); byte[] buf = new byte[64 * 1024]; while (!def.finished()) { int n = def.deflate(buf); if (n == 0) break; out.write(buf, 0, n); } return out.toByteArray(); } finally { def.end(); } } private static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException { MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update((byte) ((width >>> 8) & 0xFF)); md.update((byte) (width & 0xFF)); md.update((byte) ((height >>> 8) & 0xFF)); md.update((byte) (height & 0xFF)); md.update(rgba); byte[] out = md.digest(); StringBuilder sb = new StringBuilder(out.length * 2); for (byte b : out) sb.append(String.format("%02x", b)); return sb.toString(); } public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) { ClientPacketListener conn = Minecraft.getInstance().getConnection(); if (conn == null) return; conn.send(new ServerboundCustomPayloadPacket(payload)); } }