shared cosmetics filter + bone stuff + fixes
parent
e8dfa02160
commit
063d85a398
@ -0,0 +1,353 @@
|
||||
package com.razz.dfashion.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||
import com.razz.dfashion.bbmodel.BbmodelBaker;
|
||||
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
|
||||
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
|
||||
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic;
|
||||
import com.razz.dfashion.skin.SkinCache;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.model.geom.ModelPart;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
import net.minecraft.client.player.LocalPlayer;
|
||||
import net.minecraft.client.renderer.texture.DynamicTexture;
|
||||
import net.minecraft.client.renderer.texture.TextureManager;
|
||||
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
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.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Client-side store for <b>shared cosmetics</b>. Holds content-addressed {@code .dfcos}
|
||||
* blobs under {@code <gameDir>/decofashion/shared_cosmetics_cache/<hash>.dfcos}, baked
|
||||
* {@code CosmeticCache.Baked} entries keyed by hash, and an in-flight download map for
|
||||
* streaming reception.
|
||||
*
|
||||
* <p>Symmetric to {@link ClientSkinCache} but with an extra bbmodel-decode step. At no
|
||||
* point on this path does any PNG decoder or JSON parser see wire bytes:
|
||||
* <ul>
|
||||
* <li>The bbmodel binary is decoded via {@link BbmodelCodec} — bounded and re-validated.</li>
|
||||
* <li>The deflated RGBA is inflated under a {@code w*h*4} cap.</li>
|
||||
* <li>The {@link NativeImage} is built pixel-by-pixel via {@code setPixelABGR}; STB is
|
||||
* never invoked with untrusted bytes.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class ClientSharedCosmeticCache {
|
||||
|
||||
private static final String SUBDIR = "decofashion/shared_cosmetics_cache";
|
||||
|
||||
private static final Map<String, CosmeticCache.Baked> BAKED = new ConcurrentHashMap<>();
|
||||
private static final Map<String, Download> IN_FLIGHT = new ConcurrentHashMap<>();
|
||||
private static final Set<String> PENDING_REQUESTS = ConcurrentHashMap.newKeySet();
|
||||
|
||||
private ClientSharedCosmeticCache() {}
|
||||
|
||||
private static final class Download {
|
||||
int expectedTotal;
|
||||
int nextIndex;
|
||||
int bbmodelLen;
|
||||
int width;
|
||||
int height;
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
}
|
||||
|
||||
private static Path root() {
|
||||
return FMLPaths.GAMEDIR.get().resolve(SUBDIR);
|
||||
}
|
||||
|
||||
private static Path fileFor(String hash) {
|
||||
return root().resolve(hash + SharedCosmeticCache.FILE_EXT);
|
||||
}
|
||||
|
||||
public static Identifier textureIdFor(String hash) {
|
||||
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "shared_cosmetics/" + hash);
|
||||
}
|
||||
|
||||
public static CosmeticCache.Baked getBaked(String hash) {
|
||||
return BAKED.get(hash);
|
||||
}
|
||||
|
||||
public static boolean hasOnDisk(String hash) {
|
||||
return SharedCosmeticCache.isValidHash(hash) && Files.isRegularFile(fileFor(hash));
|
||||
}
|
||||
|
||||
/**
|
||||
* If we don't have a baked entry and haven't already asked the server, send one
|
||||
* {@link RequestCosmetic}. Deduped so the render layer can call every frame without
|
||||
* spamming. Pure in-memory dispatch — no disk I/O on the render thread.
|
||||
*/
|
||||
public static void requestIfMissing(String hash) {
|
||||
if (!SharedCosmeticCache.isValidHash(hash)) return;
|
||||
if (BAKED.containsKey(hash)) return;
|
||||
if (IN_FLIGHT.containsKey(hash)) return;
|
||||
if (!PENDING_REQUESTS.add(hash)) return;
|
||||
IN_FLIGHT.put(hash, new Download());
|
||||
sendToServer(new RequestCosmetic(hash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume one server→client chunk. Every field is validated against the first-chunk
|
||||
* state; mismatches clear in-flight state.
|
||||
*/
|
||||
public static void onChunk(String hash, int index, int total, int bbmodelLen,
|
||||
int width, int height, byte[] data) {
|
||||
if (!SharedCosmeticCache.isValidHash(hash)) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic chunk: bad hash");
|
||||
return;
|
||||
}
|
||||
if (total <= 0 || index < 0 || index >= total) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: bad chunk {}/{}", hash, index, total);
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
if (bbmodelLen <= 0 || bbmodelLen > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: bad bbmodelLen {}", hash, bbmodelLen);
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
if (width <= 0 || height <= 0
|
||||
|| width > SharedCosmeticCache.MAX_DIM
|
||||
|| height > SharedCosmeticCache.MAX_DIM) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: bad dims {}x{}", hash, width, height);
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
if (data == null || data.length > SharedCosmeticCache.MAX_CHUNK_BYTES) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk too large", hash);
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
|
||||
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.bbmodelLen = bbmodelLen;
|
||||
d.width = width;
|
||||
d.height = height;
|
||||
d.buffer.reset();
|
||||
} else if (d.expectedTotal != total || d.nextIndex != index
|
||||
|| d.bbmodelLen != bbmodelLen || d.width != width || d.height != height) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: chunk mismatch at {}/{}", hash, index, total);
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
|
||||
int maxTotal = SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES + SharedCosmeticCache.MAX_DEFLATED_BYTES;
|
||||
if (d.buffer.size() + data.length > maxTotal) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: exceeds total cap", hash);
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
d.buffer.write(data);
|
||||
} catch (IOException ex) {
|
||||
IN_FLIGHT.remove(hash);
|
||||
return;
|
||||
}
|
||||
d.nextIndex = index + 1;
|
||||
if (index + 1 < total) return;
|
||||
|
||||
IN_FLIGHT.remove(hash);
|
||||
PENDING_REQUESTS.remove(hash);
|
||||
finalizeDownload(hash, d);
|
||||
}
|
||||
|
||||
private static void finalizeDownload(String hash, Download d) {
|
||||
byte[] payload = d.buffer.toByteArray();
|
||||
if (payload.length < d.bbmodelLen) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: payload shorter than bbmodelLen", hash);
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] bbmodelBin = new byte[d.bbmodelLen];
|
||||
System.arraycopy(payload, 0, bbmodelBin, 0, d.bbmodelLen);
|
||||
byte[] deflatedRgba = new byte[payload.length - d.bbmodelLen];
|
||||
System.arraycopy(payload, d.bbmodelLen, deflatedRgba, 0, deflatedRgba.length);
|
||||
|
||||
// Bake first; only write the blob to disk after it validates. Writing first would
|
||||
// leave a rejected blob lingering on disk, where scanDisk would keep re-hydrating it
|
||||
// and failing forever.
|
||||
if (!bakeAndRegister(hash, bbmodelBin, d.width, d.height, deflatedRgba)) {
|
||||
return;
|
||||
}
|
||||
byte[] blob = SharedCosmeticCache.buildBlob(bbmodelBin, d.width, d.height, deflatedRgba);
|
||||
try {
|
||||
Files.createDirectories(root());
|
||||
Files.write(fileFor(hash), blob);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: disk write failed ({})", hash, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a cached blob back into the baked cache without a round-trip. If bake fails,
|
||||
* the on-disk file is deleted so a subsequent {@link #scanDisk} doesn't keep re-trying
|
||||
* a permanently-broken blob.
|
||||
*/
|
||||
public static void rehydrateFromDisk(String hash) throws IOException {
|
||||
if (!SharedCosmeticCache.isValidHash(hash)) throw new IOException("invalid hash");
|
||||
byte[] blob = Files.readAllBytes(fileFor(hash));
|
||||
SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob);
|
||||
if (view == null) {
|
||||
Files.deleteIfExists(fileFor(hash));
|
||||
throw new IOException("blob corrupt");
|
||||
}
|
||||
boolean ok = bakeAndRegister(hash, view.bbmodelBinary(), view.width(), view.height(), view.deflatedRgba());
|
||||
if (!ok) {
|
||||
Files.deleteIfExists(fileFor(hash));
|
||||
throw new IOException("bake rejected");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode bbmodel, inflate RGBA, build {@link NativeImage}, register with
|
||||
* {@link TextureManager}, bake the ModelPart tree, and publish into the render cache.
|
||||
*/
|
||||
/**
|
||||
* Returns {@code true} iff the blob fully decoded and was registered. Callers use the
|
||||
* return value to gate disk persistence — a failed bake must never leave a blob on
|
||||
* disk that subsequent {@code scanDisk} calls would keep re-hydrating and failing.
|
||||
*/
|
||||
private static boolean bakeAndRegister(String hash, byte[] bbmodelBin,
|
||||
int width, int height, byte[] deflatedRgba) {
|
||||
Bbmodel bbmodel;
|
||||
ByteBuf in = Unpooled.wrappedBuffer(bbmodelBin);
|
||||
try {
|
||||
bbmodel = BbmodelCodec.CODEC.decode(in);
|
||||
if (in.readableBytes() != 0) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: trailing bytes after bbmodel decode", hash);
|
||||
return false;
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: bbmodel rejected ({})", hash, ex.getMessage());
|
||||
return false;
|
||||
} finally {
|
||||
in.release();
|
||||
}
|
||||
|
||||
int expectedRaw;
|
||||
try {
|
||||
expectedRaw = SkinCache.rgbaByteCount(width, height);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: {}", hash, ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
byte[] rgba;
|
||||
try {
|
||||
rgba = SkinCache.inflateBounded(deflatedRgba, expectedRaw);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: inflate failed ({})", hash, ex.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
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 texId = textureIdFor(hash);
|
||||
tm.register(texId, new DynamicTexture(() -> "decofashion shared " + hash, img));
|
||||
|
||||
Map<String, ModelPart> parts = BbmodelBaker.bake(
|
||||
bbmodel, bbmodel.resolutionWidth(), bbmodel.resolutionHeight(), true);
|
||||
BAKED.put(hash, new CosmeticCache.Baked(parts, texId));
|
||||
DecoFashion.LOGGER.info("shared cosmetic ready: hash={} dims={}x{} parts={}",
|
||||
hash, width, height, parts.keySet());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a cached shared cosmetic from this client — releases its texture, forgets the
|
||||
* baked entry, removes the local cache file. The server's copy is untouched.
|
||||
*/
|
||||
public static boolean delete(String hash) {
|
||||
if (!SharedCosmeticCache.isValidHash(hash)) return false;
|
||||
CosmeticCache.Baked removed = BAKED.remove(hash);
|
||||
boolean changed = removed != null;
|
||||
if (removed != null) {
|
||||
try {
|
||||
Minecraft.getInstance().getTextureManager().release(removed.texture());
|
||||
} catch (Throwable t) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: texture release failed ({})", hash, t.getMessage());
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (Files.deleteIfExists(fileFor(hash))) changed = true;
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("shared cosmetic {}: file delete failed ({})", hash, ex.getMessage());
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload every cached blob into the baked map so shared cosmetics render without a
|
||||
* round-trip after a restart. Run at client setup (equivalent to {@code ClientSkinCache.scanDisk}).
|
||||
*/
|
||||
public static void scanDisk() {
|
||||
Path dir = root();
|
||||
if (!Files.isDirectory(dir)) return;
|
||||
int loaded = 0;
|
||||
int expectedNameLen = 64 + SharedCosmeticCache.FILE_EXT.length();
|
||||
try (java.nio.file.DirectoryStream<Path> stream =
|
||||
Files.newDirectoryStream(dir, "*" + SharedCosmeticCache.FILE_EXT)) {
|
||||
for (Path p : stream) {
|
||||
String name = p.getFileName().toString();
|
||||
if (name.length() != expectedNameLen) continue;
|
||||
String hash = name.substring(0, 64);
|
||||
if (!SharedCosmeticCache.isValidHash(hash)) continue;
|
||||
if (BAKED.containsKey(hash)) continue;
|
||||
try {
|
||||
rehydrateFromDisk(hash);
|
||||
loaded++;
|
||||
} catch (Exception ex) {
|
||||
DecoFashion.LOGGER.warn("shared cache: failed to load {} ({})",
|
||||
hash, ex.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("shared cache scan failed: {}", ex.getMessage());
|
||||
}
|
||||
if (loaded > 0) DecoFashion.LOGGER.info("shared cache: loaded {} cosmetic(s) from disk", loaded);
|
||||
}
|
||||
|
||||
/** Server-authoritative dedupe check — is this hash already in the local player's library? */
|
||||
public static boolean libraryContains(String hash) {
|
||||
LocalPlayer player = Minecraft.getInstance().player;
|
||||
if (player == null) return false;
|
||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||
return lib.contains(hash);
|
||||
}
|
||||
|
||||
public static void sendToServer(CustomPacketPayload payload) {
|
||||
ClientPacketListener conn = Minecraft.getInstance().getConnection();
|
||||
if (conn == null) return;
|
||||
conn.send(new ServerboundCustomPayloadPacket(payload));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
package com.razz.dfashion.client;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
import com.razz.dfashion.bbmodel.BbModelParser;
|
||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
|
||||
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
|
||||
import com.razz.dfashion.skin.SafePngReader;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.zip.Deflater;
|
||||
|
||||
/**
|
||||
* Author-side upload pipeline for shared cosmetics. Runs only on the uploading client.
|
||||
*
|
||||
* <p>Security-critical: this is the <b>only</b> place in the entire system where
|
||||
* {@link BbModelParser} and {@link SafePngReader} see untrusted bytes. Everything
|
||||
* downstream (server, receivers) operates on the validated binary produced here and on
|
||||
* already-deflated RGBA. The output of this file is what every other client will
|
||||
* eventually decode — if this file doesn't reject something, nothing else will either.
|
||||
*
|
||||
* <p>Anti-DoS: before sending, the server-authoritative {@link
|
||||
* ClientSharedCosmeticCache#libraryContains} check short-circuits uploads that the
|
||||
* server already has in this player's library. Re-uploads of identical content are
|
||||
* therefore zero-bandwidth after the first success.
|
||||
*/
|
||||
public final class ClientSharedCosmeticUploader {
|
||||
|
||||
private ClientSharedCosmeticUploader() {}
|
||||
|
||||
public static String upload(Path bbmodelFile, Path textureFile, String displayName) {
|
||||
String result = doUpload(bbmodelFile, textureFile, displayName);
|
||||
DecoFashion.LOGGER.info("Shared cosmetic upload: {}", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String doUpload(Path bbmodelFile, Path textureFile, String displayName) {
|
||||
if (!Files.isRegularFile(bbmodelFile)) return "Not a file: " + bbmodelFile;
|
||||
if (!Files.isRegularFile(textureFile)) return "Not a file: " + textureFile;
|
||||
|
||||
long bbSize;
|
||||
long texSize;
|
||||
try {
|
||||
bbSize = Files.size(bbmodelFile);
|
||||
texSize = Files.size(textureFile);
|
||||
} catch (IOException ex) {
|
||||
return "Size check failed: " + ex.getMessage();
|
||||
}
|
||||
if (bbSize > 16L * 1024 * 1024) return "bbmodel too large: " + bbSize;
|
||||
if (texSize > SafeImageLoader.MAX_PNG_FILE_BYTES) return "PNG too large: " + texSize;
|
||||
|
||||
// 1. Parse bbmodel via BbModelParser (validates everything).
|
||||
Bbmodel bbmodel;
|
||||
try (Reader reader = Files.newBufferedReader(bbmodelFile)) {
|
||||
bbmodel = BbModelParser.parse(reader);
|
||||
} catch (Exception ex) {
|
||||
return "bbmodel rejected: " + ex.getMessage();
|
||||
}
|
||||
|
||||
// 2. Decode PNG via SafePngReader (memory-safe, RGBA only).
|
||||
SafePngReader.Image decoded;
|
||||
try {
|
||||
byte[] pngBytes = Files.readAllBytes(textureFile);
|
||||
decoded = SafePngReader.decode(pngBytes);
|
||||
} catch (IOException ex) {
|
||||
return "PNG rejected: " + ex.getMessage();
|
||||
}
|
||||
|
||||
// 3. Encode bbmodel canonically via BbmodelCodec. This is the form that will cross
|
||||
// the wire, be stored, hashed, and eventually decoded by every receiver.
|
||||
byte[] canonicalBbmodelBin;
|
||||
ByteBuf out = Unpooled.buffer(1024);
|
||||
try {
|
||||
BbmodelCodec.CODEC.encode(out, bbmodel);
|
||||
if (out.readableBytes() > SharedCosmeticCache.MAX_BBMODEL_BIN_BYTES) {
|
||||
return "canonical bbmodel too large: " + out.readableBytes();
|
||||
}
|
||||
canonicalBbmodelBin = new byte[out.readableBytes()];
|
||||
out.readBytes(canonicalBbmodelBin);
|
||||
} finally {
|
||||
out.release();
|
||||
}
|
||||
|
||||
// 4. Compute content hash — must match what the server will compute.
|
||||
String hash;
|
||||
try {
|
||||
hash = SharedCosmeticCache.hashContent(canonicalBbmodelBin,
|
||||
decoded.width, decoded.height, decoded.rgba);
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
return "SHA-256 unavailable";
|
||||
}
|
||||
|
||||
// 5. Anti-DoS: if the server already has this hash in our library, skip the wire.
|
||||
if (ClientSharedCosmeticCache.libraryContains(hash)) {
|
||||
return "Already in library (" + hash.substring(0, 8) + "…); skipped";
|
||||
}
|
||||
|
||||
// 6. Deflate raw RGBA.
|
||||
byte[] deflated = deflate(decoded.rgba);
|
||||
if (deflated.length > SharedCosmeticCache.MAX_DEFLATED_BYTES) {
|
||||
return "RGBA deflated too large: " + deflated.length;
|
||||
}
|
||||
|
||||
ClientPacketListener conn = Minecraft.getInstance().getConnection();
|
||||
if (conn == null) return "Not connected";
|
||||
|
||||
// 7. Assemble wire payload = bbmodel binary || deflated RGBA. Chunk and send.
|
||||
int bbmodelLen = canonicalBbmodelBin.length;
|
||||
int payloadLen = bbmodelLen + deflated.length;
|
||||
int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES;
|
||||
int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
int off = i * chunkSize;
|
||||
int len = Math.min(chunkSize, payloadLen - off);
|
||||
byte[] slice = sliceConcat(canonicalBbmodelBin, deflated, off, len);
|
||||
conn.send(new ServerboundCustomPayloadPacket(
|
||||
new UploadCosmeticChunk(
|
||||
i, total, bbmodelLen,
|
||||
decoded.width, decoded.height,
|
||||
slice
|
||||
)));
|
||||
}
|
||||
|
||||
return "Uploading " + decoded.width + "x" + decoded.height + " ("
|
||||
+ payloadLen + " bytes, " + total + " chunks, hash "
|
||||
+ hash.substring(0, 8) + "…) [" + displayName + "]";
|
||||
}
|
||||
|
||||
/** Slice bytes from {@code a ‖ b} into a new array of length {@code len} starting at {@code off}. */
|
||||
private static byte[] sliceConcat(byte[] a, byte[] b, int off, int len) {
|
||||
byte[] out = new byte[len];
|
||||
int dst = 0;
|
||||
if (off < a.length) {
|
||||
int take = Math.min(len, a.length - off);
|
||||
System.arraycopy(a, off, out, dst, take);
|
||||
dst += take;
|
||||
len -= take;
|
||||
off += take;
|
||||
}
|
||||
if (len > 0) {
|
||||
int bOff = off - a.length;
|
||||
System.arraycopy(b, bOff, out, dst, len);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.razz.dfashion.client;
|
||||
|
||||
import com.mojang.blaze3d.platform.NativeImage;
|
||||
import com.razz.dfashion.skin.SafePngReader;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* The only sanctioned bridge from a user-supplied PNG file on disk to a {@link NativeImage}.
|
||||
*
|
||||
* <p>Runs the PNG through {@link SafePngReader} — pure Java, no STB, 8-bit RGBA only, hard
|
||||
* dim cap, IHDR/IDAT/IEND whitelist, CRC-checked, zero trailing bytes — then builds the
|
||||
* {@code NativeImage} by writing pixels directly with {@code setPixelABGR}. {@code NativeImage.read}
|
||||
* (and therefore STB) is never invoked with untrusted bytes. Mirrors the pattern already used on
|
||||
* the skin download path in {@link ClientSkinCache}.
|
||||
*/
|
||||
public final class SafeImageLoader {
|
||||
|
||||
/** Hard cap on a PNG file we'll even read off disk. A 4096² 8-bit-RGBA PNG is ≤ ~67 MB
|
||||
* uncompressed; real authored content is well under 20 MB. */
|
||||
public static final long MAX_PNG_FILE_BYTES = 64L * 1024 * 1024;
|
||||
|
||||
private SafeImageLoader() {}
|
||||
|
||||
public static NativeImage loadNativeImage(Path file) throws IOException {
|
||||
long size = Files.size(file);
|
||||
if (size > MAX_PNG_FILE_BYTES) {
|
||||
throw new IOException("PNG file too large: " + size + " > " + MAX_PNG_FILE_BYTES);
|
||||
}
|
||||
byte[] png = Files.readAllBytes(file);
|
||||
SafePngReader.Image decoded = SafePngReader.decode(png);
|
||||
NativeImage img = new NativeImage(decoded.width, decoded.height, false);
|
||||
int src = 0;
|
||||
for (int y = 0; y < decoded.height; y++) {
|
||||
for (int x = 0; x < decoded.width; x++) {
|
||||
int r = decoded.rgba[src++] & 0xFF;
|
||||
int g = decoded.rgba[src++] & 0xFF;
|
||||
int b = decoded.rgba[src++] & 0xFF;
|
||||
int a = decoded.rgba[src++] & 0xFF;
|
||||
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
|
||||
}
|
||||
}
|
||||
return img;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.razz.dfashion.cosmetic;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.handler.codec.DecoderException;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* One of two ways an equipped cosmetic slot can reference its content:
|
||||
* <ul>
|
||||
* <li>{@link Local} — a built-in or user-folder cosmetic addressed by {@link Identifier}.
|
||||
* Resolved against {@code CosmeticCache.cosmetics}.</li>
|
||||
* <li>{@link Shared} — a server-side blob addressed by its 64-char content hash.
|
||||
* Resolved against {@code ClientSharedCosmeticCache}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Wire codec tags the variant with a single byte (0 = local, 1 = shared) and caps each
|
||||
* inner string length. Invalid shared hashes are rejected via
|
||||
* {@link SharedCosmeticCache#isValidHash} on decode.
|
||||
*/
|
||||
public sealed interface CosmeticRef permits CosmeticRef.Local, CosmeticRef.Shared {
|
||||
|
||||
record Local(Identifier id) implements CosmeticRef {}
|
||||
record Shared(String hash) implements CosmeticRef {}
|
||||
|
||||
byte TAG_LOCAL = 0;
|
||||
byte TAG_SHARED = 1;
|
||||
|
||||
Codec<CosmeticRef> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||
Codec.BOOL.fieldOf("shared").forGetter(r -> r instanceof Shared),
|
||||
Codec.STRING.fieldOf("value").forGetter(r -> switch (r) {
|
||||
case Local l -> l.id().toString();
|
||||
case Shared s -> s.hash();
|
||||
})
|
||||
).apply(inst, (shared, value) -> shared
|
||||
? new Shared(value)
|
||||
: new Local(Identifier.parse(value))));
|
||||
|
||||
StreamCodec<ByteBuf, CosmeticRef> STREAM_CODEC = new StreamCodec<>() {
|
||||
private final StreamCodec<ByteBuf, String> LOCAL_STR = ByteBufCodecs.stringUtf8(256);
|
||||
private final StreamCodec<ByteBuf, String> SHARED_STR = ByteBufCodecs.stringUtf8(64);
|
||||
|
||||
@Override public void encode(ByteBuf buf, CosmeticRef ref) {
|
||||
switch (ref) {
|
||||
case Local l -> {
|
||||
buf.writeByte(TAG_LOCAL);
|
||||
LOCAL_STR.encode(buf, l.id().toString());
|
||||
}
|
||||
case Shared s -> {
|
||||
buf.writeByte(TAG_SHARED);
|
||||
SHARED_STR.encode(buf, s.hash());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override public CosmeticRef decode(ByteBuf buf) {
|
||||
byte tag = buf.readByte();
|
||||
return switch (tag) {
|
||||
case TAG_LOCAL -> {
|
||||
String s = LOCAL_STR.decode(buf);
|
||||
Identifier id = Identifier.tryParse(s);
|
||||
if (id == null) throw new DecoderException("bad Identifier in CosmeticRef.Local: " + s);
|
||||
yield new Local(id);
|
||||
}
|
||||
case TAG_SHARED -> {
|
||||
String hash = SHARED_STR.decode(buf);
|
||||
if (!SharedCosmeticCache.isValidHash(hash)) {
|
||||
throw new DecoderException("invalid hash in CosmeticRef.Shared");
|
||||
}
|
||||
yield new Shared(hash);
|
||||
}
|
||||
default -> throw new DecoderException("bad CosmeticRef tag " + tag);
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,189 @@
|
||||
package com.razz.dfashion.cosmetic.share;
|
||||
|
||||
import com.razz.dfashion.bbmodel.BbGroup;
|
||||
import com.razz.dfashion.bbmodel.BbOutlinerNode;
|
||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Extract the top-level bone set from a parsed bbmodel, and map bones to wardrobe
|
||||
* categories for filtering + auto-equip.
|
||||
*
|
||||
* <p>Per the "bone parenting" convention: the top-level {@code GroupRef}s in
|
||||
* {@link Bbmodel#outliner()} are the cosmetic's attachment bones — their names match
|
||||
* player-model bones like {@code Head}, {@code Body}, {@code LeftArm}, etc. We resolve
|
||||
* each top-level group ref to its {@link BbGroup}, take the group's name, and normalize
|
||||
* to a canonical key. The bone set is stored on the cosmetic library entry and drives:
|
||||
* <ol>
|
||||
* <li>wardrobe filtering — a cosmetic with {@code Head} shows on hat / head tabs,</li>
|
||||
* <li>auto-equip — {@link #primaryCategory} picks the default slot when the user
|
||||
* clicks equip without choosing a category.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public final class BoneExtraction {
|
||||
|
||||
private BoneExtraction() {}
|
||||
|
||||
/** Canonical player-bone keys. Matches vanilla {@code HumanoidModel} part names. */
|
||||
public static final String HEAD = "Head";
|
||||
public static final String BODY = "Body";
|
||||
public static final String LEFT_ARM = "LeftArm";
|
||||
public static final String RIGHT_ARM = "RightArm";
|
||||
public static final String LEFT_LEG = "LeftLeg";
|
||||
public static final String RIGHT_LEG = "RightLeg";
|
||||
/** Unrecognized bbmodel group name — cosmetic is still renderable but filters won't match it. */
|
||||
public static final String OTHER = "Other";
|
||||
|
||||
/**
|
||||
* Walk {@code bbmodel.outliner()} and return canonical bone names ordered by how
|
||||
* much geometry (recursive element count) each bone contains — most first. Ties
|
||||
* break by the priority order body → head → arms → legs for determinism.
|
||||
*
|
||||
* <p>This lets auto-equip pick the <b>dominant</b> bone as the cosmetic's primary
|
||||
* category: a hat with 10 cubes on {@code Head} and 1 stray on {@code Body} still
|
||||
* equips as a hat; a full outfit with 20 cubes on {@code Body} and 6 each on arms/
|
||||
* legs still equips as torso. The author doesn't need a slot field.
|
||||
*
|
||||
* <p>{@link #OTHER} bones contribute no count and aren't stored — they'd never
|
||||
* resolve to a category anyway.
|
||||
*/
|
||||
public static List<String> fromBbmodel(Bbmodel bbmodel) {
|
||||
Map<String, BbGroup> groupsByUuid = new HashMap<>(bbmodel.groups().size());
|
||||
for (BbGroup g : bbmodel.groups()) {
|
||||
groupsByUuid.put(g.uuid(), g);
|
||||
}
|
||||
|
||||
// Accumulate canonical-bone → recursive element count. Top-level groups with
|
||||
// the same canonical name (e.g. two "arm_l" typos) merge their counts. Empty
|
||||
// bones (rigged groups with no geometry) are discarded — common authoring
|
||||
// pattern is to rig all six player bones and drop cubes into just one.
|
||||
Map<String, Integer> counts = new LinkedHashMap<>();
|
||||
for (BbOutlinerNode node : bbmodel.outliner()) {
|
||||
if (!(node instanceof BbOutlinerNode.GroupRef gr)) continue;
|
||||
BbGroup g = groupsByUuid.get(gr.uuid());
|
||||
if (g == null) continue;
|
||||
String bone = canonical(g.name());
|
||||
if (OTHER.equals(bone)) continue;
|
||||
int n = countElementsRecursive(gr);
|
||||
if (n <= 0) continue;
|
||||
counts.merge(bone, n, Integer::sum);
|
||||
}
|
||||
|
||||
List<Map.Entry<String, Integer>> sorted = new ArrayList<>(counts.entrySet());
|
||||
sorted.sort((a, b) -> {
|
||||
int byCount = Integer.compare(b.getValue(), a.getValue()); // count desc
|
||||
if (byCount != 0) return byCount;
|
||||
return Integer.compare(priority(a.getKey()), priority(b.getKey())); // priority asc
|
||||
});
|
||||
|
||||
List<String> result = new ArrayList<>(sorted.size());
|
||||
for (Map.Entry<String, Integer> e : sorted) result.add(e.getKey());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Recursive element (cube) count under a group, following nested groups. */
|
||||
private static int countElementsRecursive(BbOutlinerNode.GroupRef groupRef) {
|
||||
int count = 0;
|
||||
for (BbOutlinerNode child : groupRef.children()) {
|
||||
if (child instanceof BbOutlinerNode.ElementRef) {
|
||||
count++;
|
||||
} else if (child instanceof BbOutlinerNode.GroupRef cgr) {
|
||||
count += countElementsRecursive(cgr);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Tie-break priority for equal geometry counts (lower index wins). */
|
||||
private static int priority(String bone) {
|
||||
return switch (bone) {
|
||||
case BODY -> 0;
|
||||
case HEAD -> 1;
|
||||
case LEFT_ARM -> 2;
|
||||
case RIGHT_ARM -> 3;
|
||||
case LEFT_LEG -> 4;
|
||||
case RIGHT_LEG -> 5;
|
||||
default -> Integer.MAX_VALUE;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive name normalization with common aliases ({@code torso} → {@code Body},
|
||||
* {@code arml} → {@code LeftArm}, etc.). Returns {@link #OTHER} for anything we don't
|
||||
* recognize — the cosmetic still bakes and renders, it just won't appear in bone-filtered
|
||||
* views.
|
||||
*/
|
||||
public static String canonical(String name) {
|
||||
if (name == null) return OTHER;
|
||||
String n = name.trim().toLowerCase().replace("_", "").replace(" ", "").replace("-", "");
|
||||
return switch (n) {
|
||||
case "head" -> HEAD;
|
||||
case "body", "torso", "chest" -> BODY;
|
||||
case "leftarm", "arml", "armleft", "larm" -> LEFT_ARM;
|
||||
case "rightarm", "armr", "armright", "rarm" -> RIGHT_ARM;
|
||||
case "leftleg", "legl", "legleft", "lleg" -> LEFT_LEG;
|
||||
case "rightleg", "legr", "legright", "rleg" -> RIGHT_LEG;
|
||||
default -> OTHER;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Which wardrobe categories should show a cosmetic with these bones? Head bone →
|
||||
* both {@code hat} and {@code head}; body → {@code torso} and {@code wings}; arm →
|
||||
* {@code arms} and {@code wrist}; leg → {@code legs} and {@code feet}. Cosmetics with
|
||||
* only {@link #OTHER} bones fall into no category and won't appear in filtered views.
|
||||
*/
|
||||
public static Set<String> categoriesFor(List<String> bones) {
|
||||
Set<String> cats = new HashSet<>();
|
||||
if (bones == null) return cats;
|
||||
for (String bone : bones) {
|
||||
switch (bone) {
|
||||
case HEAD -> {
|
||||
cats.add("hat");
|
||||
cats.add("head");
|
||||
}
|
||||
case BODY -> {
|
||||
cats.add("torso");
|
||||
cats.add("wings");
|
||||
}
|
||||
case LEFT_ARM, RIGHT_ARM -> {
|
||||
cats.add("arms");
|
||||
cats.add("wrist");
|
||||
}
|
||||
case LEFT_LEG, RIGHT_LEG -> {
|
||||
cats.add("legs");
|
||||
cats.add("feet");
|
||||
}
|
||||
default -> { /* OTHER — no category */ }
|
||||
}
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the default equip category for a cosmetic with these bones. {@link #fromBbmodel}
|
||||
* orders bones by geometry count (most first), so the first bone is the cosmetic's
|
||||
* dominant attachment — that's the slot it lands in.
|
||||
*
|
||||
* <p>Returns {@code null} if no canonical bone is present; the caller should fall
|
||||
* back to a sentinel category (e.g. {@code "particle"}) so the cosmetic remains
|
||||
* equippable.
|
||||
*/
|
||||
public static String primaryCategory(List<String> bones) {
|
||||
if (bones == null || bones.isEmpty()) return null;
|
||||
return switch (bones.get(0)) {
|
||||
case BODY -> "torso";
|
||||
case HEAD -> "hat";
|
||||
case LEFT_ARM, RIGHT_ARM -> "arms";
|
||||
case LEFT_LEG, RIGHT_LEG -> "legs";
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.razz.dfashion.cosmetic.share;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A player's personal shared-cosmetic library — a bounded list of {@link CosmeticLibraryEntry}s.
|
||||
*
|
||||
* <p>Persisted on the player via an attachment and synced only to the owning client (like
|
||||
* {@code SkinLibrary}). Other players never receive anyone else's library. Each entry's
|
||||
* {@code hash} resolves to a {@code .dfcos} blob in {@code SharedCosmeticCache}.
|
||||
*/
|
||||
public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
|
||||
|
||||
/** Per-player cap. Cosmetics have multiple slots and benefit from more variants than skins. */
|
||||
public static final int MAX_ENTRIES = 15;
|
||||
|
||||
public static final CosmeticLibrary EMPTY = new CosmeticLibrary(List.of());
|
||||
|
||||
public static final Codec<CosmeticLibrary> CODEC =
|
||||
CosmeticLibraryEntry.CODEC.listOf().xmap(CosmeticLibrary::new, CosmeticLibrary::entries);
|
||||
|
||||
public static final StreamCodec<ByteBuf, CosmeticLibrary> STREAM_CODEC =
|
||||
CosmeticLibraryEntry.STREAM_CODEC
|
||||
.apply(ByteBufCodecs.list())
|
||||
.map(CosmeticLibrary::new, CosmeticLibrary::entries);
|
||||
|
||||
public boolean contains(String hash) {
|
||||
for (CosmeticLibraryEntry e : entries) {
|
||||
if (e.hash().equals(hash)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isFull() {
|
||||
return entries.size() >= MAX_ENTRIES;
|
||||
}
|
||||
|
||||
public CosmeticLibrary add(CosmeticLibraryEntry entry) {
|
||||
List<CosmeticLibraryEntry> out = new ArrayList<>(entries.size() + 1);
|
||||
out.addAll(entries);
|
||||
out.add(entry);
|
||||
return new CosmeticLibrary(List.copyOf(out));
|
||||
}
|
||||
|
||||
public CosmeticLibrary remove(String hash) {
|
||||
List<CosmeticLibraryEntry> out = new ArrayList<>(entries.size());
|
||||
for (CosmeticLibraryEntry e : entries) {
|
||||
if (!e.hash().equals(hash)) out.add(e);
|
||||
}
|
||||
return new CosmeticLibrary(List.copyOf(out));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.razz.dfashion.cosmetic.share;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One entry in a player's personal shared-cosmetic library. {@code hash} points at a
|
||||
* {@code .dfcos} blob the server stores under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}.
|
||||
* {@code displayName} is a user-visible label; {@code uploadedAt} is epoch millis so the
|
||||
* wardrobe can sort without touching the filesystem. {@code bones} is the canonical set
|
||||
* of player bones the cosmetic parents to — drives wardrobe filtering and auto-equip
|
||||
* category, extracted from the bbmodel at upload finalization via {@link BoneExtraction}.
|
||||
*/
|
||||
public record CosmeticLibraryEntry(
|
||||
String hash,
|
||||
String displayName,
|
||||
long uploadedAt,
|
||||
List<String> bones
|
||||
) {
|
||||
|
||||
/** Cap on bone list size. Real cosmetics declare ≤ 6 top-level bones; 16 is slack. */
|
||||
public static final int MAX_BONES = 16;
|
||||
/** Cap on a single bone key's length. Canonical keys are ≤ 8 chars; 32 is slack. */
|
||||
public static final int MAX_BONE_LEN = 32;
|
||||
|
||||
public CosmeticLibraryEntry {
|
||||
bones = bones == null ? List.of() : List.copyOf(bones);
|
||||
}
|
||||
|
||||
public static final Codec<CosmeticLibraryEntry> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||
Codec.STRING.fieldOf("hash").forGetter(CosmeticLibraryEntry::hash),
|
||||
Codec.STRING.optionalFieldOf("name", "").forGetter(CosmeticLibraryEntry::displayName),
|
||||
Codec.LONG.optionalFieldOf("uploadedAt", 0L).forGetter(CosmeticLibraryEntry::uploadedAt),
|
||||
Codec.STRING.listOf().optionalFieldOf("bones", List.of()).forGetter(CosmeticLibraryEntry::bones)
|
||||
).apply(inst, CosmeticLibraryEntry::new));
|
||||
|
||||
public static final StreamCodec<ByteBuf, CosmeticLibraryEntry> STREAM_CODEC = StreamCodec.composite(
|
||||
ByteBufCodecs.stringUtf8(64), CosmeticLibraryEntry::hash,
|
||||
ByteBufCodecs.stringUtf8(128), CosmeticLibraryEntry::displayName,
|
||||
ByteBufCodecs.VAR_LONG, CosmeticLibraryEntry::uploadedAt,
|
||||
ByteBufCodecs.stringUtf8(MAX_BONE_LEN).apply(ByteBufCodecs.list(MAX_BONES)),
|
||||
CosmeticLibraryEntry::bones,
|
||||
CosmeticLibraryEntry::new
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,357 @@
|
||||
package com.razz.dfashion.cosmetic.share;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||
import com.razz.dfashion.cosmetic.CosmeticRef;
|
||||
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
|
||||
import com.razz.dfashion.cosmetic.share.packet.CosmeticChunk;
|
||||
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
|
||||
import com.razz.dfashion.cosmetic.share.packet.RequestCosmetic;
|
||||
import com.razz.dfashion.cosmetic.share.packet.UploadCosmeticChunk;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.neoforged.api.distmarker.Dist;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.common.EventBusSubscriber;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
||||
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
||||
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Wires up the shared-cosmetic payloads.
|
||||
*
|
||||
* <p>Server side is authoritative on everything: hash validity, library membership,
|
||||
* content decoding, and the final content hash. Clients never supply a hash on upload —
|
||||
* the server computes it from the decoded content. Every inbound path validates the hash
|
||||
* against {@link SharedCosmeticCache#isValidHash} before any filesystem access.
|
||||
*/
|
||||
@EventBusSubscriber(modid = DecoFashion.MODID)
|
||||
public final class CosmeticShareNetwork {
|
||||
|
||||
/** Bumped on wire-breaking changes. Separate from the skin channel's version. */
|
||||
private static final String VERSION = "1";
|
||||
|
||||
private CosmeticShareNetwork() {}
|
||||
|
||||
@SubscribeEvent
|
||||
static void onRegister(RegisterPayloadHandlersEvent event) {
|
||||
PayloadRegistrar registrar = event.registrar(VERSION);
|
||||
|
||||
registrar.playToServer(
|
||||
UploadCosmeticChunk.TYPE, UploadCosmeticChunk.STREAM_CODEC,
|
||||
CosmeticShareNetwork::onUploadChunk
|
||||
);
|
||||
registrar.playToServer(
|
||||
RequestCosmetic.TYPE, RequestCosmetic.STREAM_CODEC,
|
||||
CosmeticShareNetwork::onRequestCosmetic
|
||||
);
|
||||
registrar.playToServer(
|
||||
DeleteCosmetic.TYPE, DeleteCosmetic.STREAM_CODEC,
|
||||
CosmeticShareNetwork::onDeleteCosmetic
|
||||
);
|
||||
registrar.playToServer(
|
||||
AssignSharedCosmetic.TYPE, AssignSharedCosmetic.STREAM_CODEC,
|
||||
CosmeticShareNetwork::onAssignShared
|
||||
);
|
||||
registrar.playToClient(
|
||||
CosmeticChunk.TYPE, CosmeticChunk.STREAM_CODEC,
|
||||
CosmeticShareNetwork::onCosmeticChunk
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear any in-flight upload state when a player disconnects so buffers don't leak. */
|
||||
@SubscribeEvent
|
||||
static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (event.getEntity() instanceof ServerPlayer sp) {
|
||||
SharedCosmeticCache.cancelInFlight(sp.getUUID());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-extract bones for every library entry on login. Cheap — a handful of blob
|
||||
* reads + bbmodel decodes, capped at {@link CosmeticLibrary#MAX_ENTRIES} — and
|
||||
* self-healing: any entry persisted under older/buggier bone-extraction logic gets
|
||||
* corrected automatically. If re-extraction fails (blob missing, decode error) the
|
||||
* entry is left untouched.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
static void onPlayerLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (!(event.getEntity() instanceof ServerPlayer player)) return;
|
||||
MinecraftServer server = player.level().getServer();
|
||||
if (server == null) return;
|
||||
|
||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||
if (lib.entries().isEmpty()) return;
|
||||
|
||||
boolean anyChanged = false;
|
||||
List<CosmeticLibraryEntry> migrated = new ArrayList<>(lib.entries().size());
|
||||
for (CosmeticLibraryEntry entry : lib.entries()) {
|
||||
List<String> extracted = extractBonesFromStoredBlob(server, entry.hash());
|
||||
if (extracted == null) {
|
||||
migrated.add(entry); // read/decode failed — leave untouched
|
||||
continue;
|
||||
}
|
||||
if (extracted.equals(entry.bones())) {
|
||||
migrated.add(entry); // already correct
|
||||
continue;
|
||||
}
|
||||
migrated.add(new CosmeticLibraryEntry(
|
||||
entry.hash(), entry.displayName(), entry.uploadedAt(), extracted));
|
||||
anyChanged = true;
|
||||
DecoFashion.LOGGER.info("Cosmetic bones refreshed: player={} hash={} old={} new={}",
|
||||
player.getUUID(), entry.hash(), entry.bones(), extracted);
|
||||
}
|
||||
CosmeticLibrary finalLib = anyChanged ? new CosmeticLibrary(migrated) : lib;
|
||||
if (anyChanged) {
|
||||
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), finalLib);
|
||||
}
|
||||
|
||||
// Second audit: drop any CosmeticRef.Shared in EQUIPPED whose hash isn't in the
|
||||
// current library. Catches zombies from older client/server builds where a delete
|
||||
// removed the library entry but left the equipped slot pointing at the dead hash.
|
||||
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||
Map<String, CosmeticRef> cleaned = null;
|
||||
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||
if (!(e.getValue() instanceof CosmeticRef.Shared s)) continue;
|
||||
if (!finalLib.contains(s.hash())) {
|
||||
if (cleaned == null) cleaned = new HashMap<>(equipped);
|
||||
cleaned.remove(e.getKey());
|
||||
DecoFashion.LOGGER.info("Cosmetic equipped-orphan cleared: player={} slot={} hash={}",
|
||||
player.getUUID(), e.getKey(), s.hash());
|
||||
}
|
||||
}
|
||||
if (cleaned != null) {
|
||||
player.setData(CosmeticAttachments.EQUIPPED.get(), cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a stored {@code .dfcos} blob, decode its bbmodel section, and extract the
|
||||
* top-level bone set. Returns {@code null} on any read/decode failure so the caller
|
||||
* leaves the entry unchanged.
|
||||
*/
|
||||
private static List<String> extractBonesFromStoredBlob(MinecraftServer server, String hash) {
|
||||
if (!SharedCosmeticCache.has(server, hash)) return null;
|
||||
byte[] blob;
|
||||
try {
|
||||
blob = SharedCosmeticCache.read(server, hash);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic bones backfill: read failed for {}: {}", hash, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob);
|
||||
if (view == null) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic bones backfill: blob corrupt for {}", hash);
|
||||
return null;
|
||||
}
|
||||
ByteBuf in = Unpooled.wrappedBuffer(view.bbmodelBinary());
|
||||
try {
|
||||
Bbmodel bbmodel = BbmodelCodec.CODEC.decode(in);
|
||||
return BoneExtraction.fromBbmodel(bbmodel);
|
||||
} catch (RuntimeException ex) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic bones backfill: decode failed for {}: {}", hash, ex.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
in.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- server-side handlers ----
|
||||
|
||||
private static void onUploadChunk(UploadCosmeticChunk msg, IPayloadContext ctx) {
|
||||
ctx.enqueueWork(() -> {
|
||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||
MinecraftServer server = player.level().getServer();
|
||||
if (server == null) return;
|
||||
|
||||
SharedCosmeticCache.Finalized finalized = SharedCosmeticCache.acceptChunk(
|
||||
server, player.getUUID(),
|
||||
msg.index(), msg.total(),
|
||||
msg.bbmodelLen(),
|
||||
msg.width(), msg.height(),
|
||||
msg.data()
|
||||
);
|
||||
if (finalized == null) return;
|
||||
|
||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||
if (!lib.contains(finalized.hash())) {
|
||||
int n = lib.entries().size() + 1;
|
||||
CosmeticLibraryEntry entry = new CosmeticLibraryEntry(
|
||||
finalized.hash(),
|
||||
"Cosmetic " + n,
|
||||
System.currentTimeMillis(),
|
||||
finalized.bones()
|
||||
);
|
||||
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.add(entry));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void onRequestCosmetic(RequestCosmetic msg, IPayloadContext ctx) {
|
||||
ctx.enqueueWork(() -> {
|
||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||
MinecraftServer server = player.level().getServer();
|
||||
if (server == null) return;
|
||||
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic request from {}: invalid hash rejected", player.getUUID());
|
||||
return;
|
||||
}
|
||||
if (!SharedCosmeticCache.has(server, msg.hash())) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic request from {}: unknown hash {}", player.getUUID(), msg.hash());
|
||||
return;
|
||||
}
|
||||
|
||||
byte[] blob;
|
||||
try {
|
||||
blob = SharedCosmeticCache.read(server, msg.hash());
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.error("Cosmetic request {}: read failed", msg.hash(), ex);
|
||||
return;
|
||||
}
|
||||
|
||||
SharedCosmeticCache.BlobView view = SharedCosmeticCache.readBlob(blob);
|
||||
if (view == null) {
|
||||
DecoFashion.LOGGER.error("Cosmetic request {}: stored blob corrupt", msg.hash());
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire payload (per-chunk) is [bbmodel binary][deflated RGBA], same shape as upload.
|
||||
int bbmodelLen = view.bbmodelBinary().length;
|
||||
int deflatedLen = view.deflatedRgba().length;
|
||||
int payloadLen = bbmodelLen + deflatedLen;
|
||||
int chunkSize = SharedCosmeticCache.MAX_CHUNK_BYTES;
|
||||
int total = Math.max(1, (payloadLen + chunkSize - 1) / chunkSize);
|
||||
|
||||
for (int i = 0; i < total; i++) {
|
||||
int off = i * chunkSize;
|
||||
int len = Math.min(chunkSize, payloadLen - off);
|
||||
byte[] slice = new byte[len];
|
||||
// Copy from (bbmodelBinary || deflatedRgba) virtual buffer without concatenating.
|
||||
copySlice(view.bbmodelBinary(), view.deflatedRgba(), off, slice);
|
||||
player.connection.send(new ClientboundCustomPayloadPacket(
|
||||
new CosmeticChunk(
|
||||
msg.hash(),
|
||||
i, total, bbmodelLen,
|
||||
view.width(), view.height(),
|
||||
slice
|
||||
)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void onAssignShared(AssignSharedCosmetic msg, IPayloadContext ctx) {
|
||||
ctx.enqueueWork(() -> {
|
||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||
String slot = msg.slot();
|
||||
if (slot == null || slot.isEmpty() || slot.length() > 64) {
|
||||
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: bad slot", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
Map<String, CosmeticRef> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
|
||||
|
||||
// Empty hash = clear this slot.
|
||||
if (msg.hash().isEmpty()) {
|
||||
equipped.remove(slot);
|
||||
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: invalid hash", player.getUUID());
|
||||
return;
|
||||
}
|
||||
|
||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||
if (!lib.contains(msg.hash())) {
|
||||
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: hash {} not in caller's library",
|
||||
player.getUUID(), msg.hash());
|
||||
return;
|
||||
}
|
||||
|
||||
equipped.put(slot, new CosmeticRef.Shared(msg.hash()));
|
||||
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
|
||||
});
|
||||
}
|
||||
|
||||
private static void onDeleteCosmetic(DeleteCosmetic msg, IPayloadContext ctx) {
|
||||
ctx.enqueueWork(() -> {
|
||||
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||
if (!SharedCosmeticCache.isValidHash(msg.hash())) {
|
||||
DecoFashion.LOGGER.warn("DeleteCosmetic from {}: invalid hash rejected", player.getUUID());
|
||||
return;
|
||||
}
|
||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||
if (!lib.contains(msg.hash())) return;
|
||||
player.setData(CosmeticAttachments.SHARED_LIBRARY.get(), lib.remove(msg.hash()));
|
||||
|
||||
// Also unequip any slots still pointing at this hash — otherwise the render
|
||||
// layer's requestIfMissing re-hydrates the blob from the server and the player
|
||||
// keeps wearing a cosmetic they just removed.
|
||||
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
|
||||
Map<String, CosmeticRef> updated = null;
|
||||
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
|
||||
if (e.getValue() instanceof CosmeticRef.Shared s && msg.hash().equals(s.hash())) {
|
||||
if (updated == null) updated = new HashMap<>(equipped);
|
||||
updated.remove(e.getKey());
|
||||
}
|
||||
}
|
||||
if (updated != null) {
|
||||
player.setData(CosmeticAttachments.EQUIPPED.get(), updated);
|
||||
}
|
||||
|
||||
DecoFashion.LOGGER.info("Cosmetic delete: player={} hash={} unequipped={}",
|
||||
player.getUUID(), msg.hash(), updated != null);
|
||||
});
|
||||
}
|
||||
|
||||
private static void onCosmeticChunk(CosmeticChunk msg, IPayloadContext ctx) {
|
||||
ctx.enqueueWork(() -> ClientReceiver.accept(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-only trampoline. Kept separate so the server-side handler routing can be
|
||||
* dist-gated without pulling in any client-only classes on dedicated servers.
|
||||
* {@link com.razz.dfashion.client.ClientSharedCosmeticCache#onChunk} re-validates
|
||||
* every field — this trampoline just delegates.
|
||||
*/
|
||||
private static final class ClientReceiver {
|
||||
static void accept(CosmeticChunk msg) {
|
||||
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
|
||||
com.razz.dfashion.client.ClientSharedCosmeticCache.onChunk(
|
||||
msg.hash(), msg.index(), msg.total(),
|
||||
msg.bbmodelLen(), msg.width(), msg.height(),
|
||||
msg.data()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Slice bytes from the virtual concatenation of {@code a ‖ b} into {@code out}, avoiding a full copy. */
|
||||
private static void copySlice(byte[] a, byte[] b, int off, byte[] out) {
|
||||
int dst = 0;
|
||||
int len = out.length;
|
||||
if (off < a.length) {
|
||||
int take = Math.min(len, a.length - off);
|
||||
System.arraycopy(a, off, out, dst, take);
|
||||
dst += take;
|
||||
len -= take;
|
||||
off += take;
|
||||
}
|
||||
if (len > 0) {
|
||||
int bOff = off - a.length;
|
||||
System.arraycopy(b, bOff, out, dst, len);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,448 @@
|
||||
package com.razz.dfashion.cosmetic.share;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||
import com.razz.dfashion.cosmetic.CosmeticAttachments;
|
||||
import com.razz.dfashion.skin.SkinCache;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
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.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Server-side shared-cosmetic store. Holds content-addressed <b>{@code .dfcos} blobs</b>
|
||||
* under {@code <serverDir>/decofashion/cosmetics/<hash>.dfcos}.
|
||||
*
|
||||
* <p>Blob layout (big-endian everywhere):
|
||||
* <pre>
|
||||
* [4 bytes] magic "DFCO"
|
||||
* [1 byte] version (currently 1)
|
||||
* [4 bytes] u32 bbmodel-binary length
|
||||
* [N bytes] bbmodel binary (per {@link BbmodelCodec})
|
||||
* [2 bytes] u16 texture width
|
||||
* [2 bytes] u16 texture height
|
||||
* [M bytes] deflated RGBA pixels
|
||||
* </pre>
|
||||
*
|
||||
* <p>The server never parses JSON or PNG on this path. Uploads arrive as already-encoded
|
||||
* bbmodel-binary + already-decoded-and-deflated RGBA from the authoring client. Every
|
||||
* read path validates the magic header before trusting a byte.
|
||||
*/
|
||||
public final class SharedCosmeticCache {
|
||||
|
||||
public static final String FILE_EXT = ".dfcos";
|
||||
public static final byte[] MAGIC = { 'D', 'F', 'C', 'O' };
|
||||
public static final byte VERSION = 1;
|
||||
|
||||
/** magic (4) + version (1) + bbmodelLen (4) + w (2) + h (2) = 13, plus the bbmodel body. */
|
||||
public static final int HEADER_FIXED_SIZE = 13;
|
||||
|
||||
/** Cap on the bbmodel binary section of a single blob. Matches the local file cap for
|
||||
* .bbmodel JSON; binary is usually ~1/3 the size of the source JSON, so this is slack. */
|
||||
public static final int MAX_BBMODEL_BIN_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/** Re-exports texture caps from the skin pipeline — same pixel-budget math applies here. */
|
||||
public static final int MAX_RAW_BYTES = SkinCache.MAX_RAW_BYTES;
|
||||
public static final int MAX_DEFLATED_BYTES = SkinCache.MAX_DEFLATED_BYTES;
|
||||
public static final int MAX_CHUNK_BYTES = SkinCache.MAX_CHUNK_BYTES;
|
||||
public static final int MAX_DIM = SkinCache.MAX_DIM;
|
||||
|
||||
private static final String SUBDIR = "decofashion/cosmetics";
|
||||
|
||||
private static final Map<UUID, Assembly> IN_FLIGHT = new ConcurrentHashMap<>();
|
||||
|
||||
private SharedCosmeticCache() {}
|
||||
|
||||
/** Parsed view of a {@code .dfcos} blob. */
|
||||
public record BlobView(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {}
|
||||
|
||||
/** Result of a successful upload — the canonical hash, dims, the canonicalized
|
||||
* bbmodel binary as stored on disk, and the extracted bone set for wardrobe
|
||||
* filtering + auto-equip category selection. */
|
||||
public record Finalized(
|
||||
String hash,
|
||||
int width,
|
||||
int height,
|
||||
byte[] canonicalBbmodelBinary,
|
||||
List<String> bones
|
||||
) {}
|
||||
|
||||
/** Per-player upload assembly state. Cleared on error, on finalize, or on explicit cancel. */
|
||||
private static final class Assembly {
|
||||
int expectedTotal;
|
||||
int nextIndex;
|
||||
int bbmodelLen;
|
||||
int width;
|
||||
int height;
|
||||
ByteArrayOutputStream buffer;
|
||||
}
|
||||
|
||||
private static Path root(MinecraftServer server) {
|
||||
return server.getServerDirectory().resolve(SUBDIR);
|
||||
}
|
||||
|
||||
/** Same 64-char lowercase-hex rule as skins — rejects {@code ".."}, slashes, null bytes. */
|
||||
public static boolean isValidHash(String hash) {
|
||||
if (hash == null || hash.length() != 64) return false;
|
||||
for (int i = 0; i < 64; i++) {
|
||||
char c = hash.charAt(i);
|
||||
boolean ok = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static Path fileFor(MinecraftServer server, String hash) {
|
||||
return root(server).resolve(hash + FILE_EXT);
|
||||
}
|
||||
|
||||
public static boolean has(MinecraftServer server, String hash) {
|
||||
return isValidHash(hash) && Files.isRegularFile(fileFor(server, hash));
|
||||
}
|
||||
|
||||
public static byte[] read(MinecraftServer server, String hash) throws IOException {
|
||||
if (!isValidHash(hash)) throw new IOException("invalid hash");
|
||||
return Files.readAllBytes(fileFor(server, hash));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate magic + parse header off a blob. Returns a {@link BlobView} on success,
|
||||
* {@code null} if the blob is short, has bad magic, or claims lengths that overflow
|
||||
* the buffer. No trust is extended to the body sections until this passes.
|
||||
*/
|
||||
public static BlobView readBlob(byte[] blob) {
|
||||
if (blob == null || blob.length < HEADER_FIXED_SIZE) return null;
|
||||
for (int i = 0; i < MAGIC.length; i++) {
|
||||
if (blob[i] != MAGIC[i]) return null;
|
||||
}
|
||||
if (blob[4] != VERSION) return null;
|
||||
|
||||
int bbmodelLen = readU32(blob, 5);
|
||||
if (bbmodelLen < 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) return null;
|
||||
|
||||
int widthOff = 9 + bbmodelLen;
|
||||
if (widthOff + 4 > blob.length) return null;
|
||||
|
||||
int w = ((blob[widthOff] & 0xFF) << 8) | (blob[widthOff + 1] & 0xFF);
|
||||
int h = ((blob[widthOff + 2] & 0xFF) << 8) | (blob[widthOff + 3] & 0xFF);
|
||||
if (w <= 0 || h <= 0 || w > MAX_DIM || h > MAX_DIM) return null;
|
||||
|
||||
byte[] bbmodel = new byte[bbmodelLen];
|
||||
System.arraycopy(blob, 9, bbmodel, 0, bbmodelLen);
|
||||
|
||||
int deflatedStart = widthOff + 4;
|
||||
int deflatedLen = blob.length - deflatedStart;
|
||||
if (deflatedLen < 0 || deflatedLen > MAX_DEFLATED_BYTES) return null;
|
||||
byte[] deflated = new byte[deflatedLen];
|
||||
System.arraycopy(blob, deflatedStart, deflated, 0, deflatedLen);
|
||||
|
||||
return new BlobView(bbmodel, w, h, deflated);
|
||||
}
|
||||
|
||||
/** Assemble a wire-format blob. Reverse of {@link #readBlob}. */
|
||||
public static byte[] buildBlob(byte[] bbmodelBinary, int width, int height, byte[] deflatedRgba) {
|
||||
if (bbmodelBinary.length > MAX_BBMODEL_BIN_BYTES) {
|
||||
throw new IllegalArgumentException("bbmodel binary too large: " + bbmodelBinary.length);
|
||||
}
|
||||
if (deflatedRgba.length > MAX_DEFLATED_BYTES) {
|
||||
throw new IllegalArgumentException("deflated RGBA too large: " + deflatedRgba.length);
|
||||
}
|
||||
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
|
||||
throw new IllegalArgumentException("bad dims " + width + "x" + height);
|
||||
}
|
||||
int size = HEADER_FIXED_SIZE + bbmodelBinary.length + deflatedRgba.length;
|
||||
byte[] out = new byte[size];
|
||||
System.arraycopy(MAGIC, 0, out, 0, 4);
|
||||
out[4] = VERSION;
|
||||
writeU32(out, 5, bbmodelBinary.length);
|
||||
System.arraycopy(bbmodelBinary, 0, out, 9, bbmodelBinary.length);
|
||||
int widthOff = 9 + bbmodelBinary.length;
|
||||
out[widthOff] = (byte) ((width >>> 8) & 0xFF);
|
||||
out[widthOff + 1] = (byte) (width & 0xFF);
|
||||
out[widthOff + 2] = (byte) ((height >>> 8) & 0xFF);
|
||||
out[widthOff + 3] = (byte) (height & 0xFF);
|
||||
System.arraycopy(deflatedRgba, 0, out, widthOff + 4, deflatedRgba.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash over {@code [bbmodelBinary || u16 w || u16 h || raw RGBA]}. Canonical across
|
||||
* deflate levels so identical content dedups regardless of who encoded it. Pass raw
|
||||
* (inflated) pixels, not the deflate stream.
|
||||
*/
|
||||
public static String hashContent(byte[] bbmodelBinary, int width, int height, byte[] rawRgba)
|
||||
throws NoSuchAlgorithmException {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
md.update(bbmodelBinary);
|
||||
md.update((byte) ((width >>> 8) & 0xFF));
|
||||
md.update((byte) (width & 0xFF));
|
||||
md.update((byte) ((height >>> 8) & 0xFF));
|
||||
md.update((byte) (height & 0xFF));
|
||||
md.update(rawRgba);
|
||||
byte[] digest = md.digest();
|
||||
StringBuilder sb = new StringBuilder(64);
|
||||
for (byte b : digest) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a finalized blob to disk. Idempotent: if the hash already exists, no-op.
|
||||
* Caller is responsible for having already content-hashed and validated the inputs.
|
||||
*/
|
||||
public static void writeIfAbsent(MinecraftServer server, String hash, byte[] blob) throws IOException {
|
||||
if (!isValidHash(hash)) throw new IOException("invalid hash");
|
||||
Files.createDirectories(root(server));
|
||||
Path target = fileFor(server, hash);
|
||||
if (!Files.isRegularFile(target)) {
|
||||
Files.write(target, blob);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one chunk of an upload from the player. Returns the {@link Finalized} result
|
||||
* once the last chunk lands, or {@code null} while more chunks are expected or if the
|
||||
* upload was rejected. In-flight state is cleared on any error.
|
||||
*
|
||||
* <p>Security posture per field:
|
||||
* <ul>
|
||||
* <li>{@code index}/{@code total}: in range, sequential, no skipping.</li>
|
||||
* <li>{@code bbmodelLen}: bounded by {@link #MAX_BBMODEL_BIN_BYTES}; must match chunk 0.</li>
|
||||
* <li>{@code width}/{@code height}: bounded by {@link #MAX_DIM}; must match chunk 0.</li>
|
||||
* <li>{@code data.length}: bounded by {@link #MAX_CHUNK_BYTES} (redundant with the wire
|
||||
* codec cap, kept as defense in depth).</li>
|
||||
* <li>Cumulative accumulated bytes: bounded by
|
||||
* {@link #MAX_BBMODEL_BIN_BYTES} + {@link #MAX_DEFLATED_BYTES}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>On the final chunk the payload is split at {@code bbmodelLen}, the bbmodel half is
|
||||
* decoded via {@link BbmodelCodec#CODEC} (which re-runs the full validator) and then
|
||||
* <b>re-encoded canonically</b> so dedup is stable across authoring clients. The RGBA
|
||||
* half is inflated under a cap set to {@code w*h*4}. The content hash is computed from
|
||||
* the canonical bbmodel + dims + raw RGBA and is authoritative — any hash the client
|
||||
* might separately claim is ignored.
|
||||
*/
|
||||
public static Finalized acceptChunk(
|
||||
MinecraftServer server, UUID playerId,
|
||||
int index, int total, int bbmodelLen,
|
||||
int width, int height, byte[] data
|
||||
) {
|
||||
if (total <= 0 || index < 0 || index >= total) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: invalid chunk {}/{}", playerId, index, total);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
if (bbmodelLen <= 0 || bbmodelLen > MAX_BBMODEL_BIN_BYTES) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad bbmodelLen {}", playerId, bbmodelLen);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bad dims {}x{}", playerId, width, height);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
if (data == null || data.length > MAX_CHUNK_BYTES) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk too large ({})",
|
||||
playerId, data == null ? -1 : data.length);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
|
||||
Assembly asm;
|
||||
if (index == 0) {
|
||||
asm = new Assembly();
|
||||
asm.expectedTotal = total;
|
||||
asm.nextIndex = 0;
|
||||
asm.bbmodelLen = bbmodelLen;
|
||||
asm.width = width;
|
||||
asm.height = height;
|
||||
asm.buffer = new ByteArrayOutputStream(Math.min(
|
||||
total * MAX_CHUNK_BYTES,
|
||||
MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES));
|
||||
IN_FLIGHT.put(playerId, asm);
|
||||
} else {
|
||||
asm = IN_FLIGHT.get(playerId);
|
||||
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index
|
||||
|| asm.bbmodelLen != bbmodelLen
|
||||
|| asm.width != width || asm.height != height) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: chunk mismatch at {}/{}",
|
||||
playerId, index, total);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int maxTotal = MAX_BBMODEL_BIN_BYTES + MAX_DEFLATED_BYTES;
|
||||
if (asm.buffer.size() + data.length > maxTotal) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: total cap exceeded", playerId);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
asm.buffer.write(data);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.error("Cosmetic upload from {}: buffer write failed", playerId, ex);
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return null;
|
||||
}
|
||||
asm.nextIndex = index + 1;
|
||||
|
||||
if (index + 1 < total) return null;
|
||||
|
||||
IN_FLIGHT.remove(playerId);
|
||||
return finalizeUpload(server, playerId, asm);
|
||||
}
|
||||
|
||||
public static void cancelInFlight(UUID playerId) {
|
||||
IN_FLIGHT.remove(playerId);
|
||||
}
|
||||
|
||||
private static Finalized finalizeUpload(MinecraftServer server, UUID playerId, Assembly asm) {
|
||||
byte[] payload = asm.buffer.toByteArray();
|
||||
if (payload.length < asm.bbmodelLen) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: payload {} shorter than declared bbmodel {}",
|
||||
playerId, payload.length, asm.bbmodelLen);
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] bbmodelBinReceived = new byte[asm.bbmodelLen];
|
||||
System.arraycopy(payload, 0, bbmodelBinReceived, 0, asm.bbmodelLen);
|
||||
byte[] deflatedRgba = new byte[payload.length - asm.bbmodelLen];
|
||||
System.arraycopy(payload, asm.bbmodelLen, deflatedRgba, 0, deflatedRgba.length);
|
||||
|
||||
// Decode + validate (BbmodelCodec re-runs BbModelParser.validate internally).
|
||||
Bbmodel bbmodel;
|
||||
ByteBuf in = Unpooled.wrappedBuffer(bbmodelBinReceived);
|
||||
try {
|
||||
bbmodel = BbmodelCodec.CODEC.decode(in);
|
||||
if (in.readableBytes() != 0) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: {} trailing bytes after bbmodel decode",
|
||||
playerId, in.readableBytes());
|
||||
return null;
|
||||
}
|
||||
} catch (RuntimeException ex) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: bbmodel rejected ({})",
|
||||
playerId, ex.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
in.release();
|
||||
}
|
||||
|
||||
// Canonical re-encode so dedup is stable across authoring clients.
|
||||
byte[] canonicalBbmodelBin;
|
||||
ByteBuf out = Unpooled.buffer(Math.max(64, bbmodelBinReceived.length));
|
||||
try {
|
||||
BbmodelCodec.CODEC.encode(out, bbmodel);
|
||||
if (out.readableBytes() > MAX_BBMODEL_BIN_BYTES) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: canonical bbmodel too large ({})",
|
||||
playerId, out.readableBytes());
|
||||
return null;
|
||||
}
|
||||
canonicalBbmodelBin = new byte[out.readableBytes()];
|
||||
out.readBytes(canonicalBbmodelBin);
|
||||
} finally {
|
||||
out.release();
|
||||
}
|
||||
|
||||
int expectedRaw;
|
||||
try {
|
||||
expectedRaw = SkinCache.rgbaByteCount(asm.width, asm.height);
|
||||
} catch (IllegalArgumentException ex) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: {}", playerId, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
byte[] rgba;
|
||||
try {
|
||||
rgba = SkinCache.inflateBounded(deflatedRgba, expectedRaw);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: inflate failed ({})",
|
||||
playerId, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
|
||||
String hash;
|
||||
try {
|
||||
hash = hashContent(canonicalBbmodelBin, asm.width, asm.height, rgba);
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Library-size gate — duplicates are free, new entries respect MAX_ENTRIES.
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(playerId);
|
||||
if (player == null) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: player disconnected before finalize", playerId);
|
||||
return null;
|
||||
}
|
||||
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
|
||||
if (!lib.contains(hash) && lib.isFull()) {
|
||||
DecoFashion.LOGGER.warn("Cosmetic upload from {}: library full ({}); rejecting {}",
|
||||
playerId, CosmeticLibrary.MAX_ENTRIES, hash);
|
||||
return null;
|
||||
}
|
||||
|
||||
byte[] blob = buildBlob(canonicalBbmodelBin, asm.width, asm.height, deflatedRgba);
|
||||
try {
|
||||
writeIfAbsent(server, hash, blob);
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.error("Cosmetic upload from {}: disk write failed for {}",
|
||||
playerId, hash, ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
List<String> bones = BoneExtraction.fromBbmodel(bbmodel);
|
||||
|
||||
DecoFashion.LOGGER.info("Cosmetic upload finalized: player={} hash={} dims={}x{} bbmodel={}B deflated={}B bones={}",
|
||||
playerId, hash, asm.width, asm.height, canonicalBbmodelBin.length, deflatedRgba.length, bones);
|
||||
return new Finalized(hash, asm.width, asm.height, canonicalBbmodelBin, bones);
|
||||
}
|
||||
|
||||
/** Enumerate stored hashes with their on-disk sizes — useful for admin tooling. */
|
||||
public static Map<String, Long> listCached(MinecraftServer server) {
|
||||
Map<String, Long> out = new HashMap<>();
|
||||
Path dir = root(server);
|
||||
if (!Files.isDirectory(dir)) return out;
|
||||
try (var stream = Files.newDirectoryStream(dir, "*" + FILE_EXT)) {
|
||||
for (Path p : stream) {
|
||||
String name = p.getFileName().toString();
|
||||
String hash = name.substring(0, name.length() - FILE_EXT.length());
|
||||
if (!isValidHash(hash)) continue;
|
||||
try {
|
||||
out.put(hash, Files.size(p));
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
DecoFashion.LOGGER.warn("SharedCosmeticCache.listCached failed: {}", ex.getMessage());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static int readU32(byte[] b, int p) {
|
||||
return ((b[p] & 0xFF) << 24)
|
||||
| ((b[p + 1] & 0xFF) << 16)
|
||||
| ((b[p + 2] & 0xFF) << 8)
|
||||
| (b[p + 3] & 0xFF);
|
||||
}
|
||||
|
||||
private static void writeU32(byte[] b, int p, int v) {
|
||||
b[p] = (byte) ((v >>> 24) & 0xFF);
|
||||
b[p + 1] = (byte) ((v >>> 16) & 0xFF);
|
||||
b[p + 2] = (byte) ((v >>> 8) & 0xFF);
|
||||
b[p + 3] = (byte) (v & 0xFF);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.razz.dfashion.cosmetic.share.packet;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Client → server. Equip a shared-library cosmetic in {@code slot}. Server validates:
|
||||
* hash shape, hash membership in the caller's own library, and then writes a
|
||||
* {@code CosmeticRef.Shared} into the player's EQUIPPED attachment. An empty hash
|
||||
* unsets the slot.
|
||||
*/
|
||||
public record AssignSharedCosmetic(String slot, String hash) implements CustomPacketPayload {
|
||||
|
||||
public static final Type<AssignSharedCosmetic> TYPE =
|
||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_shared_cosmetic"));
|
||||
|
||||
public static final StreamCodec<FriendlyByteBuf, AssignSharedCosmetic> STREAM_CODEC =
|
||||
StreamCodec.composite(
|
||||
ByteBufCodecs.stringUtf8(64), AssignSharedCosmetic::slot,
|
||||
ByteBufCodecs.stringUtf8(64), AssignSharedCosmetic::hash,
|
||||
AssignSharedCosmetic::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<AssignSharedCosmetic> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.razz.dfashion.cosmetic.share.packet;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Server → client. One chunk of a stored shared cosmetic being streamed down in response
|
||||
* to a {@link RequestCosmetic}. Shape mirrors {@link UploadCosmeticChunk} with an extra
|
||||
* {@code hash} field so the receiving client can route to the right in-flight download.
|
||||
*
|
||||
* <p>Same wire-safety properties as the upload path: no PNG, no JSON. Just
|
||||
* {@code [bbmodel binary][deflated RGBA]} with dims metadata per chunk.
|
||||
*/
|
||||
public record CosmeticChunk(
|
||||
String hash,
|
||||
int index,
|
||||
int total,
|
||||
int bbmodelLen,
|
||||
int width,
|
||||
int height,
|
||||
byte[] data
|
||||
) implements CustomPacketPayload {
|
||||
|
||||
public static final Type<CosmeticChunk> TYPE =
|
||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "cosmetic_chunk"));
|
||||
|
||||
public static final StreamCodec<FriendlyByteBuf, CosmeticChunk> STREAM_CODEC =
|
||||
StreamCodec.composite(
|
||||
ByteBufCodecs.stringUtf8(64), CosmeticChunk::hash,
|
||||
ByteBufCodecs.VAR_INT, CosmeticChunk::index,
|
||||
ByteBufCodecs.VAR_INT, CosmeticChunk::total,
|
||||
ByteBufCodecs.VAR_INT, CosmeticChunk::bbmodelLen,
|
||||
ByteBufCodecs.VAR_INT, CosmeticChunk::width,
|
||||
ByteBufCodecs.VAR_INT, CosmeticChunk::height,
|
||||
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),CosmeticChunk::data,
|
||||
CosmeticChunk::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<CosmeticChunk> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.razz.dfashion.cosmetic.share.packet;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Client → server. Remove {@code hash} from the caller's personal shared-cosmetic library.
|
||||
* Only affects the caller's own library — cannot reach into other players' libraries.
|
||||
* Unknown hashes are silently ignored (idempotent).
|
||||
*/
|
||||
public record DeleteCosmetic(String hash) implements CustomPacketPayload {
|
||||
|
||||
public static final Type<DeleteCosmetic> TYPE =
|
||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "delete_cosmetic"));
|
||||
|
||||
public static final StreamCodec<FriendlyByteBuf, DeleteCosmetic> STREAM_CODEC =
|
||||
StreamCodec.composite(
|
||||
ByteBufCodecs.stringUtf8(64), DeleteCosmetic::hash,
|
||||
DeleteCosmetic::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<DeleteCosmetic> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.razz.dfashion.cosmetic.share.packet;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Client → server. Asks the server to stream back the blob for {@code hash}. Server
|
||||
* rejects on invalid or unknown hashes — the hash is validated via
|
||||
* {@code SharedCosmeticCache.isValidHash} before any filesystem access.
|
||||
*/
|
||||
public record RequestCosmetic(String hash) implements CustomPacketPayload {
|
||||
|
||||
public static final Type<RequestCosmetic> TYPE =
|
||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_cosmetic"));
|
||||
|
||||
public static final StreamCodec<FriendlyByteBuf, RequestCosmetic> STREAM_CODEC =
|
||||
StreamCodec.composite(
|
||||
ByteBufCodecs.stringUtf8(64), RequestCosmetic::hash,
|
||||
RequestCosmetic::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<RequestCosmetic> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package com.razz.dfashion.cosmetic.share.packet;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Client → server. One chunk of a shared-cosmetic upload. The payload bytes are the
|
||||
* concatenation {@code [bbmodel binary][deflated RGBA]}; the authoring client knows both
|
||||
* halves up-front so {@code bbmodelLen}, {@code width}, and {@code height} are repeated
|
||||
* on every chunk for defense-in-depth mismatch detection on the server.
|
||||
*
|
||||
* <p>No PNG container and no JSON ever crosses this wire. The bbmodel binary is produced
|
||||
* by {@link com.razz.dfashion.cosmetic.share.BbmodelCodec} on the author's machine; the
|
||||
* RGBA has already been decoded by {@code SafePngReader} and deflated locally.
|
||||
*/
|
||||
public record UploadCosmeticChunk(
|
||||
int index,
|
||||
int total,
|
||||
int bbmodelLen,
|
||||
int width,
|
||||
int height,
|
||||
byte[] data
|
||||
) implements CustomPacketPayload {
|
||||
|
||||
public static final Type<UploadCosmeticChunk> TYPE =
|
||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "upload_cosmetic_chunk"));
|
||||
|
||||
public static final StreamCodec<FriendlyByteBuf, UploadCosmeticChunk> STREAM_CODEC =
|
||||
StreamCodec.composite(
|
||||
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::index,
|
||||
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::total,
|
||||
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::bbmodelLen,
|
||||
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::width,
|
||||
ByteBufCodecs.VAR_INT, UploadCosmeticChunk::height,
|
||||
ByteBufCodecs.byteArray(SharedCosmeticCache.MAX_CHUNK_BYTES),UploadCosmeticChunk::data,
|
||||
UploadCosmeticChunk::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<UploadCosmeticChunk> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package com.razz.dfashion.skin;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A player's personal skin library — a bounded list of {@link SkinLibraryEntry}s.
|
||||
*
|
||||
* <p>Persisted on the player (via an attachment) and synced only to the owning client.
|
||||
* The wardrobe UI renders this list for the local player; other players never receive
|
||||
* anyone else's library. The local {@code ClientSkinCache} still holds a content-addressed
|
||||
* pool of blobs for every skin this client has ever seen, but the wardrobe no longer
|
||||
* treats that pool as "mine" — it renders the server-authoritative library instead.
|
||||
*/
|
||||
public record SkinLibrary(List<SkinLibraryEntry> entries) {
|
||||
|
||||
public static final int MAX_ENTRIES = 5;
|
||||
public static final SkinLibrary EMPTY = new SkinLibrary(List.of());
|
||||
|
||||
public static final Codec<SkinLibrary> CODEC =
|
||||
SkinLibraryEntry.CODEC.listOf().xmap(SkinLibrary::new, SkinLibrary::entries);
|
||||
|
||||
public static final StreamCodec<ByteBuf, SkinLibrary> STREAM_CODEC =
|
||||
SkinLibraryEntry.STREAM_CODEC
|
||||
.apply(ByteBufCodecs.list())
|
||||
.map(SkinLibrary::new, SkinLibrary::entries);
|
||||
|
||||
public boolean contains(String hash) {
|
||||
for (SkinLibraryEntry e : entries) {
|
||||
if (e.hash().equals(hash)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean isFull() {
|
||||
return entries.size() >= MAX_ENTRIES;
|
||||
}
|
||||
|
||||
public SkinLibrary add(SkinLibraryEntry entry) {
|
||||
List<SkinLibraryEntry> out = new ArrayList<>(entries.size() + 1);
|
||||
out.addAll(entries);
|
||||
out.add(entry);
|
||||
return new SkinLibrary(List.copyOf(out));
|
||||
}
|
||||
|
||||
public SkinLibrary remove(String hash) {
|
||||
List<SkinLibraryEntry> out = new ArrayList<>(entries.size());
|
||||
for (SkinLibraryEntry e : entries) {
|
||||
if (!e.hash().equals(hash)) out.add(e);
|
||||
}
|
||||
return new SkinLibrary(List.copyOf(out));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.razz.dfashion.skin;
|
||||
|
||||
import com.mojang.serialization.Codec;
|
||||
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
|
||||
/**
|
||||
* One entry in a player's personal skin library. {@code hash} points at a blob the
|
||||
* server stores (or can fetch) under {@code <serverDir>/decofashion/skins/<hash>.bin}.
|
||||
* {@code displayName} is a user-visible label shown in the wardrobe; {@code uploadedAt}
|
||||
* is epoch millis at the time of upload, handy for sort-by-recent without needing to
|
||||
* hit the filesystem.
|
||||
*/
|
||||
public record SkinLibraryEntry(String hash, String displayName, long uploadedAt) {
|
||||
|
||||
public static final Codec<SkinLibraryEntry> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||
Codec.STRING.fieldOf("hash").forGetter(SkinLibraryEntry::hash),
|
||||
Codec.STRING.optionalFieldOf("name", "").forGetter(SkinLibraryEntry::displayName),
|
||||
Codec.LONG.optionalFieldOf("uploadedAt", 0L).forGetter(SkinLibraryEntry::uploadedAt)
|
||||
).apply(inst, SkinLibraryEntry::new));
|
||||
|
||||
public static final StreamCodec<ByteBuf, SkinLibraryEntry> STREAM_CODEC = StreamCodec.composite(
|
||||
ByteBufCodecs.STRING_UTF8, SkinLibraryEntry::hash,
|
||||
ByteBufCodecs.STRING_UTF8, SkinLibraryEntry::displayName,
|
||||
ByteBufCodecs.VAR_LONG, SkinLibraryEntry::uploadedAt,
|
||||
SkinLibraryEntry::new
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.razz.dfashion.skin.packet;
|
||||
|
||||
import com.razz.dfashion.DecoFashion;
|
||||
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.codec.ByteBufCodecs;
|
||||
import net.minecraft.network.codec.StreamCodec;
|
||||
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||
import net.minecraft.resources.Identifier;
|
||||
|
||||
/**
|
||||
* Client → server. Remove {@code hash} from the player's personal skin library. If the
|
||||
* hash was the currently-equipped skin, the server also resets {@code SkinData} back to
|
||||
* vanilla. Unknown hashes are silently ignored (idempotent).
|
||||
*/
|
||||
public record DeleteSkin(String hash) implements CustomPacketPayload {
|
||||
|
||||
public static final Type<DeleteSkin> TYPE =
|
||||
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "delete_skin"));
|
||||
|
||||
public static final StreamCodec<FriendlyByteBuf, DeleteSkin> STREAM_CODEC =
|
||||
StreamCodec.composite(
|
||||
ByteBufCodecs.stringUtf8(64), DeleteSkin::hash,
|
||||
DeleteSkin::new
|
||||
);
|
||||
|
||||
@Override
|
||||
public Type<DeleteSkin> type() {
|
||||
return TYPE;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,178 @@
|
||||
package com.razz.dfashion.security;
|
||||
|
||||
import com.code_intelligence.jazzer.junit.FuzzTest;
|
||||
import com.google.gson.JsonParseException;
|
||||
import com.razz.dfashion.bbmodel.BbModelParser;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.StringReader;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Security corpus + fuzz harness for {@link BbModelParser}. The parser runs only on the
|
||||
* author's local disk, but still handles untrusted JSON (the author could have downloaded
|
||||
* a malicious .bbmodel), so every rejection rule matters.
|
||||
*
|
||||
* <p>Fuzz inputs are fed as UTF-8 byte arrays. Legitimate rejections are any subtype of
|
||||
* {@link JsonParseException} (including {@code BadBbmodelException}). A {@link StackOverflowError},
|
||||
* {@link NullPointerException}, or any non-JsonParseException is a bug Jazzer should surface.
|
||||
*/
|
||||
class BbModelParserSecurityTest {
|
||||
|
||||
@Test
|
||||
void acceptsMinimalValidModel() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[]," +
|
||||
"\"groups\":[]," +
|
||||
"\"outliner\":[]" +
|
||||
"}";
|
||||
assertDoesNotThrow(() -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOldFormatVersion() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"4.5\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[],\"groups\":[],\"outliner\":[]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsResolutionOverCap() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":" + (BbModelParser.MAX_RESOLUTION + 1) + ",\"height\":64}," +
|
||||
"\"elements\":[],\"groups\":[],\"outliner\":[]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNanFloat() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," +
|
||||
"\"from\":[\"NaN\",0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0]," +
|
||||
"\"inflate\":0,\"faces\":{}}]," +
|
||||
"\"groups\":[],\"outliner\":[\"a\"]" +
|
||||
"}";
|
||||
// GSON parses NaN-as-string into Float.NaN (depending on lenient mode); post-parse
|
||||
// validation rejects non-finite floats. Either way, a JsonParseException is expected.
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInfinityFloat() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," +
|
||||
"\"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0]," +
|
||||
"\"rotation\":[\"Infinity\",0,0]," +
|
||||
"\"inflate\":0,\"faces\":{}}]," +
|
||||
"\"groups\":[],\"outliner\":[\"a\"]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCoordOutOfRange() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[{\"type\":\"cube\",\"uuid\":\"a\",\"name\":\"n\"," +
|
||||
"\"from\":[1e9,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0]," +
|
||||
"\"inflate\":0,\"faces\":{}}]," +
|
||||
"\"groups\":[],\"outliner\":[\"a\"]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateElementUuid() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[" +
|
||||
" {\"type\":\"cube\",\"uuid\":\"dup\",\"name\":\"n\"," +
|
||||
" \"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0],\"inflate\":0,\"faces\":{}}," +
|
||||
" {\"type\":\"cube\",\"uuid\":\"dup\",\"name\":\"n\"," +
|
||||
" \"from\":[0,0,0],\"to\":[1,1,1],\"origin\":[0,0,0],\"rotation\":[0,0,0],\"inflate\":0,\"faces\":{}}" +
|
||||
"]," +
|
||||
"\"groups\":[],\"outliner\":[\"dup\"]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOutlinerRefToUnknownElement() {
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[]," +
|
||||
"\"groups\":[]," +
|
||||
"\"outliner\":[\"ghost-uuid\"]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOutlinerOverDepthCap() {
|
||||
// Build a chain of groups deeper than MAX_OUTLINER_DEPTH. Each group is a GroupRef
|
||||
// whose single child is the next GroupRef.
|
||||
int depth = BbModelParser.MAX_OUTLINER_DEPTH + 5;
|
||||
StringBuilder groups = new StringBuilder();
|
||||
StringBuilder outliner = new StringBuilder();
|
||||
for (int i = 0; i < depth; i++) {
|
||||
if (i > 0) groups.append(',');
|
||||
groups.append("{\"uuid\":\"g").append(i).append("\",\"name\":\"g\"," +
|
||||
"\"origin\":[0,0,0],\"rotation\":[0,0,0]}");
|
||||
}
|
||||
// Outliner is a nested object tree referencing g0 → g1 → ... → g(depth-1).
|
||||
for (int i = 0; i < depth; i++) {
|
||||
outliner.append("{\"uuid\":\"g").append(i).append("\",\"children\":[");
|
||||
}
|
||||
for (int i = 0; i < depth; i++) outliner.append("]}");
|
||||
|
||||
String json = "{" +
|
||||
"\"meta\":{\"format_version\":\"5.0\"}," +
|
||||
"\"resolution\":{\"width\":64,\"height\":64}," +
|
||||
"\"elements\":[]," +
|
||||
"\"groups\":[" + groups + "]," +
|
||||
"\"outliner\":[" + outliner + "]" +
|
||||
"}";
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader(json)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedJson() {
|
||||
assertThrows(JsonParseException.class, () -> BbModelParser.parse(new StringReader("{ not json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmpty() {
|
||||
assertThrows(Exception.class, () -> BbModelParser.parse(new StringReader("")));
|
||||
}
|
||||
|
||||
// ---- fuzz harness ----
|
||||
|
||||
/**
|
||||
* Fuzz the JSON parser with random UTF-8. Legit rejection = anything JsonParseException-ish.
|
||||
* Other exceptions point at missing defense in the parser.
|
||||
*/
|
||||
@FuzzTest(maxDuration = "30s")
|
||||
void fuzzParse(byte[] input) {
|
||||
try {
|
||||
BbModelParser.parse(new StringReader(new String(input, java.nio.charset.StandardCharsets.UTF_8)));
|
||||
} catch (JsonParseException | IllegalStateException | NumberFormatException expected) {
|
||||
// All legitimate rejection paths from GSON + validator.
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,203 @@
|
||||
package com.razz.dfashion.security;
|
||||
|
||||
import com.code_intelligence.jazzer.junit.FuzzTest;
|
||||
import com.razz.dfashion.bbmodel.BbCube;
|
||||
import com.razz.dfashion.bbmodel.BbGroup;
|
||||
import com.razz.dfashion.bbmodel.BbLocator;
|
||||
import com.razz.dfashion.bbmodel.BbOutlinerNode;
|
||||
import com.razz.dfashion.bbmodel.Bbmodel;
|
||||
import com.razz.dfashion.cosmetic.share.BbmodelCodec;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.joml.Vector3f;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Security corpus + fuzz harness for {@link BbmodelCodec}. This codec is the only path
|
||||
* from network bytes to a {@link Bbmodel} on server and on receiving clients, so every
|
||||
* decode-time rejection is load-bearing.
|
||||
*
|
||||
* <p>The fuzz harness feeds random bytes to {@code decode}. The only acceptable outcome
|
||||
* is a successful decode (arbitrarily unlikely from random bytes) or a
|
||||
* {@link RuntimeException} subtype used for rejection ({@code DecoderException},
|
||||
* {@code IndexOutOfBoundsException} from ByteBuf underflow, {@code JsonParseException}
|
||||
* from validator). Other exceptions or errors indicate missing defense.
|
||||
*/
|
||||
class BbmodelCodecSecurityTest {
|
||||
|
||||
@Test
|
||||
void roundTripEmptyModel() {
|
||||
Bbmodel original = new Bbmodel(64, 64, List.of(), List.of(), List.of(), List.of());
|
||||
Bbmodel decoded = roundTrip(original);
|
||||
assertEquals(64, decoded.resolutionWidth());
|
||||
assertEquals(64, decoded.resolutionHeight());
|
||||
assertTrue(decoded.elements().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripSingleCube() {
|
||||
BbCube cube = new BbCube(
|
||||
"uuid-1", "cube-name",
|
||||
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||
0f,
|
||||
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0),
|
||||
Map.of()
|
||||
);
|
||||
Bbmodel original = new Bbmodel(64, 64,
|
||||
List.of(cube), List.of(), List.of(),
|
||||
List.of(new BbOutlinerNode.ElementRef("uuid-1")));
|
||||
Bbmodel decoded = roundTrip(original);
|
||||
assertEquals(1, decoded.elements().size());
|
||||
assertEquals("uuid-1", decoded.elements().get(0).uuid());
|
||||
}
|
||||
|
||||
@Test
|
||||
void roundTripWithGroupsAndLocators() {
|
||||
BbCube cube = new BbCube("c", "cube",
|
||||
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||
0f,
|
||||
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0),
|
||||
Map.of());
|
||||
BbLocator loc = new BbLocator("l", "loc",
|
||||
new Vector3f(1, 2, 3), new Vector3f(0, 0, 0));
|
||||
BbGroup grp = new BbGroup("g", "group",
|
||||
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0));
|
||||
Bbmodel original = new Bbmodel(64, 64,
|
||||
List.of(cube), List.of(loc), List.of(grp),
|
||||
List.of(new BbOutlinerNode.GroupRef("g", List.of(
|
||||
new BbOutlinerNode.ElementRef("c"),
|
||||
new BbOutlinerNode.ElementRef("l")
|
||||
))));
|
||||
Bbmodel decoded = roundTrip(original);
|
||||
assertEquals(1, decoded.groups().size());
|
||||
assertEquals(1, decoded.locators().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDecodeOfEmptyBuf() {
|
||||
ByteBuf buf = Unpooled.buffer();
|
||||
try {
|
||||
assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(buf));
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDecodeOfAllZeros() {
|
||||
// Length-prefixed lists will read zero counts; resolution will be 0x0. Validator
|
||||
// rejects because resolution < 1.
|
||||
ByteBuf buf = Unpooled.wrappedBuffer(new byte[64]);
|
||||
try {
|
||||
assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(buf));
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsExcessiveListCount() {
|
||||
// Craft a bbmodel binary whose "elements" VarInt says MAX_LIST_ELEMENTS + 1.
|
||||
ByteBuf buf = Unpooled.buffer();
|
||||
try {
|
||||
net.minecraft.network.VarInt.write(buf, 64); // width
|
||||
net.minecraft.network.VarInt.write(buf, 64); // height
|
||||
net.minecraft.network.VarInt.write(buf, BbmodelCodec.MAX_LIST_ELEMENTS + 1); // elements count
|
||||
ByteBuf finalBuf = buf;
|
||||
assertThrows(RuntimeException.class, () -> BbmodelCodec.CODEC.decode(finalBuf));
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTrailingBytes() {
|
||||
// A valid decode is followed by extra bytes — callers MUST verify
|
||||
// readableBytes() == 0 after decode (we do so in SharedCosmeticCache/uploader).
|
||||
// Codec itself doesn't enforce that; this test documents the contract.
|
||||
Bbmodel original = new Bbmodel(64, 64, List.of(), List.of(), List.of(), List.of());
|
||||
ByteBuf buf = Unpooled.buffer();
|
||||
try {
|
||||
BbmodelCodec.CODEC.encode(buf, original);
|
||||
buf.writeBytes(new byte[]{1, 2, 3, 4}); // trailing
|
||||
BbmodelCodec.CODEC.decode(buf);
|
||||
assertTrue(buf.readableBytes() > 0, "caller must observe trailing bytes");
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void canonicalEncodeSortsFaceKeys() {
|
||||
// If we encode the same cube twice but with different HashMap iteration orders
|
||||
// for faces, the resulting binaries must be identical (canonicalness for dedup).
|
||||
java.util.Map<String, com.razz.dfashion.bbmodel.BbFace> a = new java.util.LinkedHashMap<>();
|
||||
a.put("north", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||
a.put("south", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||
a.put("east", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||
java.util.Map<String, com.razz.dfashion.bbmodel.BbFace> b = new java.util.LinkedHashMap<>();
|
||||
b.put("south", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||
b.put("east", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||
b.put("north", new com.razz.dfashion.bbmodel.BbFace(new float[]{0, 0, 1, 1}, 0, 0));
|
||||
|
||||
BbCube cubeA = new BbCube("u", "n",
|
||||
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||
0f,
|
||||
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), a);
|
||||
BbCube cubeB = new BbCube("u", "n",
|
||||
new Vector3f(0, 0, 0), new Vector3f(1, 1, 1),
|
||||
0f,
|
||||
new Vector3f(0, 0, 0), new Vector3f(0, 0, 0), b);
|
||||
Bbmodel modelA = new Bbmodel(64, 64, List.of(cubeA), List.of(), List.of(),
|
||||
List.of(new BbOutlinerNode.ElementRef("u")));
|
||||
Bbmodel modelB = new Bbmodel(64, 64, List.of(cubeB), List.of(), List.of(),
|
||||
List.of(new BbOutlinerNode.ElementRef("u")));
|
||||
|
||||
assertArrayEquals(encode(modelA), encode(modelB),
|
||||
"Canonical encode must be order-independent over face maps");
|
||||
}
|
||||
|
||||
// ---- fuzz harness ----
|
||||
|
||||
@FuzzTest(maxDuration = "30s")
|
||||
void fuzzDecode(byte[] input) {
|
||||
ByteBuf buf = Unpooled.wrappedBuffer(input);
|
||||
try {
|
||||
BbmodelCodec.CODEC.decode(buf);
|
||||
} catch (RuntimeException expected) {
|
||||
// Every rejection path throws a runtime exception; that's intended.
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static Bbmodel roundTrip(Bbmodel m) {
|
||||
ByteBuf buf = Unpooled.buffer();
|
||||
try {
|
||||
BbmodelCodec.CODEC.encode(buf, m);
|
||||
return BbmodelCodec.CODEC.decode(buf);
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] encode(Bbmodel m) {
|
||||
ByteBuf buf = Unpooled.buffer();
|
||||
try {
|
||||
BbmodelCodec.CODEC.encode(buf, m);
|
||||
byte[] out = new byte[buf.readableBytes()];
|
||||
buf.readBytes(out);
|
||||
return out;
|
||||
} finally {
|
||||
buf.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
package com.razz.dfashion.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
/**
|
||||
* Source-level guard against accidental reintroduction of dangerous APIs.
|
||||
*
|
||||
* <p>The mod deliberately avoids calling untrusted-bytes-eating APIs (STB PNG decoder,
|
||||
* ImageIO, GSON's stream parsers, Java serialization) anywhere outside of a tiny pinned
|
||||
* allowlist. Every entry in {@link #ALLOWLIST} is a place we've audited and know is safe
|
||||
* — if you want to add a new call site, either it's a new trusted boundary (update this
|
||||
* allowlist with a comment explaining why), or you need to route through the existing
|
||||
* safe path ({@code SafePngReader}, {@code BbmodelCodec}).
|
||||
*
|
||||
* <p>Walking source files textually has false-positive risk (tokens appearing in comments
|
||||
* or string literals). The fix for false positives is to allowlist the specific file,
|
||||
* not to weaken the check.
|
||||
*/
|
||||
class ForbiddenApiGuardTest {
|
||||
|
||||
/**
|
||||
* Each key is a forbidden token; each value is the set of filenames (basenames) where
|
||||
* it's legitimate. Empty set means "forbidden everywhere".
|
||||
*
|
||||
* <p><b>Maintenance rule:</b> when adding a filename here, also add a one-line comment
|
||||
* explaining the trust boundary that makes the usage safe.
|
||||
*/
|
||||
private static final Map<String, Set<String>> ALLOWLIST = new LinkedHashMap<>();
|
||||
static {
|
||||
// STB-backed PNG decoder. The only sanctioned way to turn user PNGs into NativeImages
|
||||
// is SafeImageLoader, which routes through SafePngReader. Both files reference the
|
||||
// API name in documentation only (verified).
|
||||
ALLOWLIST.put("NativeImage.read", Set.of("SafePngReader.java", "SafeImageLoader.java"));
|
||||
|
||||
// Java ImageIO — historically has CVEs. Never acceptable.
|
||||
ALLOWLIST.put("ImageIO.read", Set.of());
|
||||
|
||||
// Stream-based JSON parser. Only the author's local bbmodel parser legitimately runs
|
||||
// GSON on untrusted bytes (and validates the result post-parse).
|
||||
ALLOWLIST.put("JsonParser.parseReader", Set.of("BbModelParser.java"));
|
||||
|
||||
// Java serialization — RCE class on untrusted input. Never acceptable.
|
||||
ALLOWLIST.put("ObjectInputStream", Set.of());
|
||||
|
||||
// Direct Gson construction. Prefer GsonBuilder so type adapters and defaults are
|
||||
// explicit. Default-config Gson is banned everywhere.
|
||||
ALLOWLIST.put("new Gson(", Set.of());
|
||||
|
||||
// GsonBuilder instances — allowed at the two trusted boundaries only:
|
||||
// BbModelParser: author's local disk (validated post-parse).
|
||||
// CosmeticCatalog: assets/decofashion/cosmetics.json from the resource pack (trusted).
|
||||
ALLOWLIST.put("new GsonBuilder(", Set.of("BbModelParser.java", "CosmeticCatalog.java"));
|
||||
}
|
||||
|
||||
/** Source root, resolved from the gradle test task's working directory (project root). */
|
||||
private static final Path SRC_MAIN = Paths.get("src/main/java");
|
||||
|
||||
@Test
|
||||
void noForbiddenApiOutsideAllowlist() throws IOException {
|
||||
if (!Files.isDirectory(SRC_MAIN)) {
|
||||
fail("src/main/java not found at " + SRC_MAIN.toAbsolutePath() + " — wrong working dir?");
|
||||
}
|
||||
|
||||
List<String> violations = new ArrayList<>();
|
||||
try (Stream<Path> files = Files.walk(SRC_MAIN)) {
|
||||
for (Path p : (Iterable<Path>) files.filter(f -> f.toString().endsWith(".java"))::iterator) {
|
||||
String fileName = p.getFileName().toString();
|
||||
String content = Files.readString(p);
|
||||
for (Map.Entry<String, Set<String>> entry : ALLOWLIST.entrySet()) {
|
||||
String token = entry.getKey();
|
||||
Set<String> okFiles = entry.getValue();
|
||||
if (content.contains(token) && !okFiles.contains(fileName)) {
|
||||
violations.add(relativeOrName(p) + " uses forbidden token: '" + token + "'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!violations.isEmpty()) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Forbidden API usage detected:\n\n");
|
||||
for (String v : violations) sb.append(" ").append(v).append('\n');
|
||||
sb.append("\nIf the usage is legitimate, add the filename to ALLOWLIST in ")
|
||||
.append(ForbiddenApiGuardTest.class.getSimpleName()).append(" with a one-line\n")
|
||||
.append("comment explaining the trust boundary. Otherwise, route through the ")
|
||||
.append("existing safe path\n(SafeImageLoader, SafePngReader, BbmodelCodec).\n");
|
||||
fail(sb.toString());
|
||||
}
|
||||
}
|
||||
|
||||
private static String relativeOrName(Path p) {
|
||||
try {
|
||||
return SRC_MAIN.relativize(p).toString();
|
||||
} catch (IllegalArgumentException ex) {
|
||||
return p.getFileName().toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,300 @@
|
||||
package com.razz.dfashion.security;
|
||||
|
||||
import com.code_intelligence.jazzer.junit.FuzzTest;
|
||||
import com.razz.dfashion.skin.SafePngReader;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.zip.CRC32;
|
||||
import java.util.zip.Deflater;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Security corpus + fuzz harness for {@link SafePngReader}.
|
||||
*
|
||||
* <p>The regression tests are a hand-curated set of "this MUST reject" inputs — malformed
|
||||
* PNGs, polyglots, bomb shapes, dimension attacks — so a future refactor can't silently
|
||||
* stop rejecting them. The fuzz harness feeds the parser random byte[] inputs; the only
|
||||
* exceptions it's allowed to throw are {@link IOException} (BadPng / truncation). Any
|
||||
* {@link RuntimeException} or {@link Error} from the parser is a bug Jazzer should find.
|
||||
*
|
||||
* <p>Run regression: {@code ./gradlew test}. Run fuzz: {@code ./gradlew test -PfuzzMinutes=5}.
|
||||
*/
|
||||
class SafePngReaderSecurityTest {
|
||||
|
||||
// ---- regression corpus ----
|
||||
|
||||
@Test
|
||||
void minimalValidRgbaPngDecodes() throws IOException {
|
||||
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||
SafePngReader.Image img = SafePngReader.decode(png);
|
||||
assertEquals(1, img.width);
|
||||
assertEquals(1, img.height);
|
||||
assertEquals(4, img.rgba.length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyInput() {
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(new byte[0]));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNullInput() {
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongMagic() {
|
||||
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||
png[0] = 'X';
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTruncatedHeader() {
|
||||
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||
byte[] truncated = new byte[png.length - 10];
|
||||
System.arraycopy(png, 0, truncated, 0, truncated.length);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(truncated));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBadCrcInIhdr() {
|
||||
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||
// IHDR CRC lives at byte offset 8 + 4 + 4 + 13 = 29. Flip the last CRC byte.
|
||||
png[29] ^= 0x01;
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsColorTypeRgb() {
|
||||
byte[] png = buildRawIhdrPng(1, 1, 8, /*colorType=*/2);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBitDepth16() {
|
||||
byte[] png = buildRawIhdrPng(1, 1, 16, /*colorType=*/6);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInterlaced() {
|
||||
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||
// IHDR interlace byte is last of 13 IHDR bytes: offset 8 + 4 + 4 + 12 = 28.
|
||||
png[28] = 1;
|
||||
// Fix CRC for the mutated IHDR.
|
||||
fixIhdrCrc(png);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDimensionOverMax() {
|
||||
byte[] png = buildRawIhdrPng(SafePngReader.MAX_DIM + 1, 1, 8, 6);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsZeroDimension() {
|
||||
byte[] png = buildRawIhdrPng(0, 1, 8, 6);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(png));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTrailingBytesAfterIend() {
|
||||
byte[] png = buildMinimalRgbaPng(1, 1);
|
||||
byte[] withTrailer = new byte[png.length + 16];
|
||||
System.arraycopy(png, 0, withTrailer, 0, png.length);
|
||||
// Simulate a polyglot trailer (e.g., ZIP magic tacked on).
|
||||
withTrailer[png.length] = 'P';
|
||||
withTrailer[png.length + 1] = 'K';
|
||||
withTrailer[png.length + 2] = 0x03;
|
||||
withTrailer[png.length + 3] = 0x04;
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(withTrailer));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateIhdr() throws IOException {
|
||||
// Build PNG with two IHDR chunks.
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(sig);
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||
writeChunk(out, "IEND", new byte[0]);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownCriticalChunk() throws IOException {
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(sig);
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "XXXX", new byte[]{1, 2, 3}); // uppercase = critical, unknown
|
||||
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||
writeChunk(out, "IEND", new byte[0]);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsUnknownAncillaryChunk() throws IOException {
|
||||
// Lowercase first letter = ancillary. Should be silently skipped, not rejected.
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(sig);
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "tEXt", "Author\0example".getBytes()); // ancillary
|
||||
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||
writeChunk(out, "IEND", new byte[0]);
|
||||
SafePngReader.Image img = SafePngReader.decode(out.toByteArray());
|
||||
assertEquals(1, img.width);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingIend() throws IOException {
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(sig);
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsIdatBeforeIhdr() throws IOException {
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(sig);
|
||||
writeChunk(out, "IDAT", idatPayload(1, 1));
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "IEND", new byte[0]);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInflatedOversize() throws IOException {
|
||||
// Declare a tiny image (1x1 = 4 bytes expected), but stuff IDAT with
|
||||
// data that inflates to much more. The bounded inflate must reject.
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
out.write(sig);
|
||||
writeChunk(out, "IHDR", ihdrPayload(1, 1, 8, 6));
|
||||
writeChunk(out, "IDAT", idatPayload(16, 16)); // produces way more than 1x1's budget
|
||||
writeChunk(out, "IEND", new byte[0]);
|
||||
assertThrows(IOException.class, () -> SafePngReader.decode(out.toByteArray()));
|
||||
}
|
||||
|
||||
// ---- fuzz harness ----
|
||||
|
||||
/**
|
||||
* Feed arbitrary byte arrays to the decoder. Jazzer will flag any exception
|
||||
* other than {@link IOException}, or any {@link Error}, as a finding. Mutates
|
||||
* around a corpus of valid PNGs by default.
|
||||
*/
|
||||
@FuzzTest(maxDuration = "30s")
|
||||
void fuzzDecode(byte[] input) {
|
||||
try {
|
||||
SafePngReader.decode(input);
|
||||
} catch (IOException expected) {
|
||||
// BadPngException, truncation, unsupported-format — all legitimate rejections.
|
||||
}
|
||||
}
|
||||
|
||||
// ---- builders ----
|
||||
|
||||
/** Construct a minimally valid 8-bit RGBA PNG with a solid-red pixel at every position. */
|
||||
private static byte[] buildMinimalRgbaPng(int w, int h) {
|
||||
return buildRawIhdrPng(w, h, 8, 6);
|
||||
}
|
||||
|
||||
private static byte[] buildRawIhdrPng(int w, int h, int bitDepth, int colorType) {
|
||||
byte[] sig = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try {
|
||||
out.write(sig);
|
||||
writeChunk(out, "IHDR", ihdrPayload(w, h, bitDepth, colorType));
|
||||
writeChunk(out, "IDAT", idatPayload(Math.max(w, 1), Math.max(h, 1)));
|
||||
writeChunk(out, "IEND", new byte[0]);
|
||||
} catch (IOException ex) {
|
||||
throw new AssertionError(ex);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
private static byte[] ihdrPayload(int w, int h, int bitDepth, int colorType) {
|
||||
byte[] p = new byte[13];
|
||||
writeU32(p, 0, w);
|
||||
writeU32(p, 4, h);
|
||||
p[8] = (byte) bitDepth;
|
||||
p[9] = (byte) colorType;
|
||||
p[10] = 0; // compression
|
||||
p[11] = 0; // filter
|
||||
p[12] = 0; // interlace
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Construct a valid IDAT: per-row filter byte (0 = None) + RGBA pixels, deflated. */
|
||||
private static byte[] idatPayload(int w, int h) {
|
||||
int rowBytes = w * 4;
|
||||
byte[] raw = new byte[h * (1 + rowBytes)];
|
||||
for (int y = 0; y < h; y++) {
|
||||
int off = y * (1 + rowBytes);
|
||||
raw[off] = 0; // filter: None
|
||||
for (int x = 0; x < w; x++) {
|
||||
int px = off + 1 + x * 4;
|
||||
raw[px] = (byte) 0xFF;
|
||||
raw[px + 1] = 0;
|
||||
raw[px + 2] = 0;
|
||||
raw[px + 3] = (byte) 0xFF;
|
||||
}
|
||||
}
|
||||
Deflater d = new Deflater(Deflater.BEST_COMPRESSION);
|
||||
try {
|
||||
d.setInput(raw);
|
||||
d.finish();
|
||||
byte[] buf = new byte[raw.length * 2 + 64];
|
||||
int n = d.deflate(buf);
|
||||
byte[] out = new byte[n];
|
||||
System.arraycopy(buf, 0, out, 0, n);
|
||||
return out;
|
||||
} finally {
|
||||
d.end();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeChunk(ByteArrayOutputStream out, String type, byte[] data) throws IOException {
|
||||
byte[] typeBytes = type.getBytes();
|
||||
if (typeBytes.length != 4) throw new AssertionError("bad chunk type");
|
||||
byte[] lenBytes = new byte[4];
|
||||
writeU32(lenBytes, 0, data.length);
|
||||
out.write(lenBytes);
|
||||
out.write(typeBytes);
|
||||
out.write(data);
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(typeBytes);
|
||||
crc.update(data);
|
||||
byte[] crcBytes = new byte[4];
|
||||
writeU32(crcBytes, 0, (int) crc.getValue());
|
||||
out.write(crcBytes);
|
||||
}
|
||||
|
||||
/** Recompute and rewrite IHDR's CRC after mutating its payload. */
|
||||
private static void fixIhdrCrc(byte[] png) {
|
||||
// IHDR starts at offset 8 (after magic). Chunk: [len=4][type=4][data=13][crc=4].
|
||||
CRC32 crc = new CRC32();
|
||||
crc.update(png, 8 + 4, 4 + 13); // type + data
|
||||
writeU32(png, 8 + 4 + 4 + 13, (int) crc.getValue());
|
||||
}
|
||||
|
||||
private static void writeU32(byte[] b, int p, int v) {
|
||||
b[p] = (byte) ((v >>> 24) & 0xFF);
|
||||
b[p + 1] = (byte) ((v >>> 16) & 0xFF);
|
||||
b[p + 2] = (byte) ((v >>> 8) & 0xFF);
|
||||
b[p + 3] = (byte) (v & 0xFF);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue