} 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));
}
}