shared cosmetics filter + bone stuff + fixes

main
MomokoKoigakubo 4 weeks ago
parent e8dfa02160
commit 063d85a398

@ -22,7 +22,7 @@ sourceSets.main.resources {
} }
repositories { repositories {
// Add here additional repositories if required by some of the dependencies below. mavenCentral()
} }
base { base {
@ -49,6 +49,12 @@ neoForge {
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
} }
client2 {
client()
programArguments.addAll '--username', 'Tester2'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
server { server {
server() server()
programArgument '--nogui' programArgument '--nogui'
@ -108,27 +114,22 @@ configurations {
} }
dependencies { dependencies {
// Example optional mod dependency with JEI // Security tests: JUnit 5 + Jazzer (coverage-guided fuzz harnesses). These never run
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime // in the mod at runtime; they're only on the test classpath.
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}" testImplementation platform('org.junit:junit-bom:5.11.3')
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}" testImplementation 'org.junit.jupiter:junit-jupiter'
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}" testImplementation 'com.code-intelligence:jazzer-junit:0.24.0'
}
// Example mod dependency using a mod jar from ./libs with a flat dir repository
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar // Regression tests run with `./gradlew test`. Jazzer @FuzzTest methods run for `maxDuration`
// The group id is ignored when searching -- in this case, it is "blank" // each by default (30s). To run a longer fuzzing session on a specific harness:
// implementation "blank:coolmod-${mc_version}:${coolmod_version}" // ./gradlew test --tests "com.razz.dfashion.security.SafePngReaderSecurityTest.fuzzDecode"
tasks.named('test', Test) {
// Example mod dependency using a file as dependency useJUnitPlatform()
// implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar") // Jazzer loads its instrumentation agent at runtime; Java 21+ requires this flag
// or the JVM warns and Jazzer may fail to attach.
// Example project dependency using a sister or child project: jvmArgs '-XX:+EnableDynamicAgentLoading'
// implementation project(":myproject")
// For more info:
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
// http://www.gradle.org/docs/current/userguide/dependency_management.html
} }
// This block of code expands all declared replace properties in the specified resource targets. // This block of code expands all declared replace properties in the specified resource targets.

@ -7,11 +7,13 @@ import com.razz.dfashion.bbmodel.BbOutlinerNode;
import com.razz.dfashion.bbmodel.Bbmodel; import com.razz.dfashion.bbmodel.Bbmodel;
import com.razz.dfashion.bbmodel.BbmodelBaker; import com.razz.dfashion.bbmodel.BbmodelBaker;
import com.razz.dfashion.block.ClosetRegistry; import com.razz.dfashion.block.ClosetRegistry;
import com.razz.dfashion.client.ClientSharedCosmeticCache;
import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.ClientSkinCache;
import com.razz.dfashion.client.ClosetModelCache; import com.razz.dfashion.client.ClosetModelCache;
import com.razz.dfashion.client.ClosetRenderer; import com.razz.dfashion.client.ClosetRenderer;
import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticCache;
import com.razz.dfashion.client.CosmeticRenderLayer; import com.razz.dfashion.client.CosmeticRenderLayer;
import com.razz.dfashion.client.SafeImageLoader;
import com.razz.dfashion.cosmetic.CosmeticCatalog; import com.razz.dfashion.cosmetic.CosmeticCatalog;
import com.razz.dfashion.cosmetic.CosmeticDefinition; import com.razz.dfashion.cosmetic.CosmeticDefinition;
import com.razz.dfashion.cosmetic.UserCosmeticLoader; import com.razz.dfashion.cosmetic.UserCosmeticLoader;
@ -38,7 +40,6 @@ import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent;
import net.neoforged.neoforge.client.event.EntityRenderersEvent; import net.neoforged.neoforge.client.event.EntityRenderersEvent;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.io.Reader; import java.io.Reader;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.HashMap; import java.util.HashMap;
@ -50,6 +51,11 @@ import java.util.Optional;
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
public class DecoFashionClient { public class DecoFashionClient {
/** Upper bound on a user-authored .bbmodel we'll even attempt to parse. Real cosmetics
* are well under 1 MB; 16 MB is slack for giant authored pieces while still blocking
* a malicious GB-scale file from being slurped into memory via {@code Files.newBufferedReader}. */
private static final long MAX_BBMODEL_FILE_BYTES = 16L * 1024 * 1024;
@SubscribeEvent @SubscribeEvent
static void onClientSetup(FMLClientSetupEvent event) { static void onClientSetup(FMLClientSetupEvent event) {
DecoFashion.LOGGER.info("DecoFashion client setup complete"); DecoFashion.LOGGER.info("DecoFashion client setup complete");
@ -94,6 +100,7 @@ public class DecoFashionClient {
// Pre-register every skin PNG that's already on disk so the wardrobe grid shows // Pre-register every skin PNG that's already on disk so the wardrobe grid shows
// the player's full history without waiting on attachment sync or re-uploads. // the player's full history without waiting on attachment sync or re-uploads.
ClientSkinCache.scanDisk(); ClientSkinCache.scanDisk();
ClientSharedCosmeticCache.scanDisk();
} }
/** /**
@ -114,25 +121,38 @@ public class DecoFashionClient {
for (UserCosmeticLoader.Entry entry : entries) { for (UserCosmeticLoader.Entry entry : entries) {
Bbmodel model = modelCache.get(entry.modelFile()); Bbmodel model = modelCache.get(entry.modelFile());
if (model == null) { if (model == null) {
try {
long size = Files.size(entry.modelFile());
if (size > MAX_BBMODEL_FILE_BYTES) {
DecoFashion.LOGGER.error("User cosmetic {}: bbmodel too large ({} bytes); skipping {}",
entry.id(), size, entry.modelFile());
continue;
}
} catch (IOException ex) {
DecoFashion.LOGGER.error("User cosmetic {}: size check failed for {}",
entry.id(), entry.modelFile(), ex);
continue;
}
try (Reader reader = Files.newBufferedReader(entry.modelFile())) { try (Reader reader = Files.newBufferedReader(entry.modelFile())) {
model = BbModelParser.parse(reader); model = BbModelParser.parse(reader);
modelCache.put(entry.modelFile(), model); modelCache.put(entry.modelFile(), model);
} catch (Exception ex) { } catch (Exception ex) {
DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {}", DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {} ({})",
entry.id(), entry.modelFile(), ex); entry.id(), entry.modelFile(), ex.getMessage());
continue; continue;
} }
} }
try (InputStream in = Files.newInputStream(entry.textureFile())) { NativeImage image;
NativeImage image = NativeImage.read(in); try {
Identifier texId = entry.def().texture(); image = SafeImageLoader.loadNativeImage(entry.textureFile());
tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image));
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("User cosmetic {}: failed to load texture {}", DecoFashion.LOGGER.error("User cosmetic {}: rejected texture {} ({})",
entry.id(), entry.textureFile(), ex); entry.id(), entry.textureFile(), ex.getMessage());
continue; continue;
} }
Identifier texId = entry.def().texture();
tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image));
Map<String, ModelPart> parts = BbmodelBaker.bake( Map<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true); model, model.resolutionWidth(), model.resolutionHeight(), true);

@ -7,15 +7,47 @@ import org.joml.Vector3f;
import java.io.Reader; import java.io.Reader;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Parses .bbmodel JSON (format_version 5.0+) into an internal geometry record. Callers
* must treat the input as untrusted: the parser enforces finite-float checks, coordinate
* range caps (to prevent NaN-by-overflow in the renderer's matrix math), resolution caps
* (to align with the texture parser's pixel budget), duplicate-uuid rejection, and outliner
* reference resolution. Element/group/locator counts are intentionally uncapped the real
* DoS bound is the file-size cap at the caller.
*/
public class BbModelParser { public class BbModelParser {
// Caps are deliberately minimal. The primary DoS defense is the file-size cap enforced
// upstream (see DecoFashionClient.MAX_BBMODEL_FILE_BYTES): a 16 MB JSON can't hide a
// billion cubes. Element/group/locator counts and outliner node totals are therefore
// NOT capped here — a complex authored cosmetic should parse without complaint.
//
// What we DO cap:
// - resolution: matches SafePngReader's MAX_DIM so UVs stay in the same pixel budget.
// - coordinates: must be finite AND within a range that won't NaN the renderer's
// matrix math (real bbmodel content lives in [-256, 256]; 1e4 is huge slack).
// - outliner depth: purely a stack-overflow guard for the recursive validator. Real
// content never breaks 20 levels; 256 is 40× slack. If legit authoring ever hits
// this, the fix is to rewrite the walk iteratively, not to raise the cap.
public static final int MAX_RESOLUTION = 4096;
public static final int MAX_OUTLINER_DEPTH = 256;
public static final float MAX_COORD = 1e4f;
private static final Gson GSON = new GsonBuilder() private static final Gson GSON = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(Vector3f.class, new Vector3fDeserializer()) .registerTypeAdapter(Vector3f.class, new Vector3fDeserializer())
.registerTypeAdapter(BbOutlinerNode.class, new BbOutlinerNode.BbOutlinerNodeDeserializer()) .registerTypeAdapter(BbOutlinerNode.class, new BbOutlinerNode.BbOutlinerNodeDeserializer())
.create(); .create();
public static final class BadBbmodelException extends JsonParseException {
public BadBbmodelException(String message) { super(message); }
}
public static Bbmodel parse(Reader in) { public static Bbmodel parse(Reader in) {
JsonObject root = JsonParser.parseReader(in).getAsJsonObject(); JsonObject root = JsonParser.parseReader(in).getAsJsonObject();
@ -36,7 +68,8 @@ public class BbModelParser {
List<BbCube> cubes = new ArrayList<>(); List<BbCube> cubes = new ArrayList<>();
List<BbLocator> locators = new ArrayList<>(); List<BbLocator> locators = new ArrayList<>();
for (JsonElement e : root.getAsJsonArray("elements")) { JsonArray elementsArr = root.has("elements") ? root.getAsJsonArray("elements") : new JsonArray();
for (JsonElement e : elementsArr) {
JsonObject obj = e.getAsJsonObject(); JsonObject obj = e.getAsJsonObject();
String type = obj.has("type") ? obj.get("type").getAsString() : "cube"; String type = obj.has("type") ? obj.get("type").getAsString() : "cube";
switch (type) { switch (type) {
@ -46,17 +79,113 @@ public class BbModelParser {
} }
} }
List<BbGroup> groups = GSON.fromJson( JsonArray groupsArr = root.has("groups") ? root.getAsJsonArray("groups") : new JsonArray();
root.getAsJsonArray("groups"), List<BbGroup> groups = GSON.fromJson(groupsArr, new TypeToken<List<BbGroup>>(){}.getType());
new TypeToken<List<BbGroup>>(){}.getType()
); JsonArray outlinerArr = root.has("outliner") ? root.getAsJsonArray("outliner") : new JsonArray();
List<BbOutlinerNode> outliner = GSON.fromJson(outlinerArr, new TypeToken<List<BbOutlinerNode>>(){}.getType());
Bbmodel model = new Bbmodel(resW, resH, cubes, locators, groups, outliner);
validate(model);
return model;
}
/** Runs all structural invariants over a {@link Bbmodel}. Exposed so wire-side codecs
* can re-validate after deserialization; see {@code BbmodelCodec}. */
public static void validate(Bbmodel m) {
requireRange("resolution.width", m.resolutionWidth(), 1, MAX_RESOLUTION);
requireRange("resolution.height", m.resolutionHeight(), 1, MAX_RESOLUTION);
// Unified id space — outliner ElementRefs can point to either a cube or a locator.
Set<String> elementIds = new HashSet<>();
for (BbCube c : m.elements()) {
requireNonNull("element.uuid", c.uuid());
validateVec("element.from", c.from());
validateVec("element.to", c.to());
validateVec("element.origin", c.origin());
validateVec("element.rotation", c.rotation());
requireFinite("element.inflate", c.inflate());
if (c.faces() != null) {
for (Map.Entry<String, BbFace> e : c.faces().entrySet()) {
BbFace f = e.getValue();
if (f != null && f.uv() != null) {
for (float uv : f.uv()) requireFinite("face.uv", uv);
}
}
}
if (!elementIds.add(c.uuid())) throw new BadBbmodelException("duplicate element uuid " + c.uuid());
}
for (BbLocator loc : m.locators()) {
requireNonNull("locator.uuid", loc.uuid());
validateVec("locator.position", loc.position());
validateVec("locator.rotation", loc.rotation());
if (!elementIds.add(loc.uuid())) throw new BadBbmodelException("duplicate locator uuid " + loc.uuid());
}
Set<String> groupIds = new HashSet<>();
for (BbGroup g : m.groups()) {
requireNonNull("group.uuid", g.uuid());
validateVec("group.origin", g.origin());
validateVec("group.rotation", g.rotation());
if (!groupIds.add(g.uuid())) throw new BadBbmodelException("duplicate group uuid " + g.uuid());
}
for (BbOutlinerNode node : m.outliner()) {
validateOutlinerNode(node, 0, elementIds, groupIds);
}
}
private static void validateOutlinerNode(
BbOutlinerNode node, int depth,
Set<String> elementIds, Set<String> groupIds
) {
// Stack-overflow guard only; not a content limit. See class-level comment.
if (depth > MAX_OUTLINER_DEPTH) {
throw new BadBbmodelException("outliner nesting exceeded stack-safety bound (>" + MAX_OUTLINER_DEPTH + ")");
}
switch (node) {
case BbOutlinerNode.ElementRef er -> {
requireNonNull("outliner.element.uuid", er.uuid());
if (!elementIds.contains(er.uuid())) {
throw new BadBbmodelException("outliner references unknown element " + er.uuid());
}
}
case BbOutlinerNode.GroupRef gr -> {
requireNonNull("outliner.group.uuid", gr.uuid());
if (!groupIds.contains(gr.uuid())) {
throw new BadBbmodelException("outliner references unknown group " + gr.uuid());
}
if (gr.children() != null) {
for (BbOutlinerNode child : gr.children()) {
validateOutlinerNode(child, depth + 1, elementIds, groupIds);
}
}
}
}
}
private static void requireRange(String label, int v, int min, int max) {
if (v < min || v > max) {
throw new BadBbmodelException(label + " out of range [" + min + "," + max + "]: " + v);
}
}
List<BbOutlinerNode> outliner = GSON.fromJson( private static void requireNonNull(String label, String s) {
root.getAsJsonArray("outliner"), if (s == null) throw new BadBbmodelException(label + " is null");
new TypeToken<List<BbOutlinerNode>>(){}.getType() }
);
private static void validateVec(String label, Vector3f v) {
if (v == null) return;
requireFinite(label + ".x", v.x);
requireFinite(label + ".y", v.y);
requireFinite(label + ".z", v.z);
if (Math.abs(v.x) > MAX_COORD || Math.abs(v.y) > MAX_COORD || Math.abs(v.z) > MAX_COORD) {
throw new BadBbmodelException(label + " out of range: (" + v.x + "," + v.y + "," + v.z + ")");
}
}
return new Bbmodel(resW, resH, cubes, locators, groups, outliner); private static void requireFinite(String label, float v) {
if (!Float.isFinite(v)) throw new BadBbmodelException(label + " is not finite: " + v);
} }

@ -5,9 +5,16 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext; import com.mojang.brigadier.context.CommandContext;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.DecoFashionClient; import com.razz.dfashion.DecoFashionClient;
import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry;
import com.razz.dfashion.cosmetic.share.SharedCosmeticCache;
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.SkinModel;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.commands.CommandSourceStack; import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands; import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
@ -16,8 +23,12 @@ import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent; import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.Locale;
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT) @EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
public final class ClientCommands { public final class ClientCommands {
@ -33,6 +44,29 @@ public final class ClientCommands {
.executes(ctx -> uploadSkin(ctx, SkinModel.WIDE)) .executes(ctx -> uploadSkin(ctx, SkinModel.WIDE))
) )
) )
.then(Commands.literal("share_cosmetic")
.then(Commands.argument("folder", StringArgumentType.greedyString())
.executes(ClientCommands::shareCosmetic)
)
)
.then(Commands.literal("list_shared").executes(ClientCommands::listShared))
.then(Commands.literal("equip_shared")
.then(Commands.argument("slot", StringArgumentType.word())
.then(Commands.argument("hash", StringArgumentType.word())
.executes(ClientCommands::equipShared)
)
)
)
.then(Commands.literal("unequip_shared")
.then(Commands.argument("slot", StringArgumentType.word())
.executes(ClientCommands::unequipShared)
)
)
.then(Commands.literal("delete_shared")
.then(Commands.argument("hash", StringArgumentType.word())
.executes(ClientCommands::deleteShared)
)
)
); );
} }
@ -61,5 +95,107 @@ public final class ClientCommands {
return 1; return 1;
} }
private static int shareCosmetic(CommandContext<CommandSourceStack> ctx) {
String raw = StringArgumentType.getString(ctx, "folder").trim();
if ((raw.startsWith("\"") && raw.endsWith("\"")) || (raw.startsWith("'") && raw.endsWith("'"))) {
raw = raw.substring(1, raw.length() - 1);
}
Path folder;
try {
folder = Paths.get(raw);
} catch (Exception ex) {
ctx.getSource().sendFailure(Component.literal("Invalid path: " + raw));
return 0;
}
if (!Files.isDirectory(folder)) {
ctx.getSource().sendFailure(Component.literal("Not a directory: " + folder));
return 0;
}
Path bbmodelFile = null;
Path textureFile = null;
try (DirectoryStream<Path> stream = Files.newDirectoryStream(folder)) {
for (Path f : stream) {
if (!Files.isRegularFile(f)) continue;
String name = f.getFileName().toString().toLowerCase(Locale.ROOT);
if (bbmodelFile == null && name.endsWith(".bbmodel")) bbmodelFile = f;
else if (textureFile == null && name.endsWith(".png")) textureFile = f;
}
} catch (IOException ex) {
ctx.getSource().sendFailure(Component.literal("Scan failed: " + ex.getMessage()));
return 0;
}
if (bbmodelFile == null) {
ctx.getSource().sendFailure(Component.literal("No .bbmodel found in " + folder));
return 0;
}
if (textureFile == null) {
ctx.getSource().sendFailure(Component.literal("No .png found in " + folder));
return 0;
}
String displayName = folder.getFileName().toString();
String msg = ClientSharedCosmeticUploader.upload(bbmodelFile, textureFile, displayName);
ctx.getSource().sendSuccess(() -> Component.literal(msg), false);
return 1;
}
private static int listShared(CommandContext<CommandSourceStack> ctx) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) {
ctx.getSource().sendFailure(Component.literal("Not in-world"));
return 0;
}
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
if (lib.entries().isEmpty()) {
ctx.getSource().sendSuccess(() -> Component.literal("Shared library is empty"), false);
return 1;
}
for (CosmeticLibraryEntry e : lib.entries()) {
ctx.getSource().sendSuccess(() -> Component.literal(
" " + e.hash().substring(0, 8) + "… " + e.displayName()), false);
}
return 1;
}
private static int equipShared(CommandContext<CommandSourceStack> ctx) {
String slot = StringArgumentType.getString(ctx, "slot");
String hash = StringArgumentType.getString(ctx, "hash");
if (!SharedCosmeticCache.isValidHash(hash)) {
ctx.getSource().sendFailure(Component.literal("Invalid hash"));
return 0;
}
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(slot, hash));
ctx.getSource().sendSuccess(
() -> Component.literal("Equipped " + hash.substring(0, 8) + "… in '" + slot + "'"),
false);
return 1;
}
private static int unequipShared(CommandContext<CommandSourceStack> ctx) {
String slot = StringArgumentType.getString(ctx, "slot");
// Empty hash = clear slot (server-side convention in AssignSharedCosmetic handler).
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(slot, ""));
ctx.getSource().sendSuccess(
() -> Component.literal("Unequipped '" + slot + "'"), false);
return 1;
}
private static int deleteShared(CommandContext<CommandSourceStack> ctx) {
String hash = StringArgumentType.getString(ctx, "hash");
if (!SharedCosmeticCache.isValidHash(hash)) {
ctx.getSource().sendFailure(Component.literal("Invalid hash"));
return 0;
}
// Two-phase delete: tell the server to remove from the library, then release the
// local blob + texture. Server is authoritative but the local cache is what the
// render layer pulls from until the library attachment sync round-trips.
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
ClientSharedCosmeticCache.delete(hash);
ctx.getSource().sendSuccess(
() -> Component.literal("Deleted " + hash.substring(0, 8) + "…"), false);
return 1;
}
private ClientCommands() {} private ClientCommands() {}
} }

@ -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 serverclient 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();
}
}
}

@ -2,6 +2,7 @@ package com.razz.dfashion.client;
import com.mojang.blaze3d.platform.NativeImage; import com.mojang.blaze3d.platform.NativeImage;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SafePngReader;
import com.razz.dfashion.skin.SkinCache; import com.razz.dfashion.skin.SkinCache;
import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.SkinModel;
import com.razz.dfashion.skin.packet.UploadSkinChunk; import com.razz.dfashion.skin.packet.UploadSkinChunk;
@ -16,17 +17,24 @@ import net.neoforged.fml.loading.FMLPaths;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.Deflater;
/** /**
* Client-side skin store. Writes received PNGs to {@code <gameDir>/decofashion/skins_cache/<hash>.png}, * Client-side skin store. Holds <b>pixel blobs</b> under
* registers them as {@link DynamicTexture}s under synthetic {@code decofashion:skins/<hash>} identifiers, * {@code <gameDir>/decofashion/skins_cache/<hash>.bin} (4-byte {@code (u16 w, u16 h)} header
* and tracks in-flight downloads keyed by hash. * + deflated RGBA body), and registers decoded images as {@link DynamicTexture}s under
* synthetic {@code decofashion:skins/<hash>} identifiers.
*
* <p>The only PNG parser in the pipeline is {@link SafePngReader} on the uploading client.
* Inbound data from the server is already raw deflated RGBA we inflate under a bounded
* cap, fill a {@link NativeImage} directly via {@code setPixelABGR}, and never invoke STB.
*/ */
public final class ClientSkinCache { public final class ClientSkinCache {
@ -38,6 +46,8 @@ public final class ClientSkinCache {
private static final class Download { private static final class Download {
int expectedTotal; int expectedTotal;
int nextIndex; int nextIndex;
int width;
int height;
ByteArrayOutputStream buffer = new ByteArrayOutputStream(); ByteArrayOutputStream buffer = new ByteArrayOutputStream();
} }
@ -48,14 +58,13 @@ public final class ClientSkinCache {
} }
private static Path fileFor(String hash) { private static Path fileFor(String hash) {
return root().resolve(hash + ".png"); return root().resolve(hash + SkinCache.FILE_EXT);
} }
public static Identifier textureIdFor(String hash) { public static Identifier textureIdFor(String hash) {
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash); return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash);
} }
/** Already have a {@link DynamicTexture} registered for this hash? */
public static boolean hasRegistered(String hash) { public static boolean hasRegistered(String hash) {
return REGISTERED.containsKey(hash); return REGISTERED.containsKey(hash);
} }
@ -64,21 +73,35 @@ public final class ClientSkinCache {
return Files.isRegularFile(fileFor(hash)); return Files.isRegularFile(fileFor(hash));
} }
/** Load from disk + register if we have the PNG cached but haven't built a texture yet. */ /** Load from disk + register if we have the blob cached but haven't built a texture yet. */
public static Identifier ensureLoadedFromDisk(String hash) { public static Identifier ensureLoadedFromDisk(String hash) {
if (!SkinCache.isValidHash(hash)) return null;
Identifier existing = REGISTERED.get(hash); Identifier existing = REGISTERED.get(hash);
if (existing != null) return existing; if (existing != null) return existing;
if (!hasOnDisk(hash)) return null; if (!hasOnDisk(hash)) return null;
try (InputStream in = Files.newInputStream(fileFor(hash))) { try {
return register(hash, in); byte[] blob = Files.readAllBytes(fileFor(hash));
int[] dims = SkinCache.readHeader(blob);
if (dims == null) {
DecoFashion.LOGGER.error("Skin cache: {} bad magic/header; ignoring", hash);
return null;
}
int w = dims[0], h = dims[1];
if (w <= 0 || h <= 0 || w > SkinCache.MAX_DIM || h > SkinCache.MAX_DIM) {
DecoFashion.LOGGER.error("Skin cache: {} has bad dims {}x{}", hash, w, h);
return null;
}
byte[] deflated = new byte[blob.length - SkinCache.HEADER_SIZE];
System.arraycopy(blob, SkinCache.HEADER_SIZE, deflated, 0, deflated.length);
byte[] rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(w, h));
return registerFromPixels(hash, w, h, rgba);
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex); DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex);
return null; return null;
} }
} }
/** Begin a chunked download for this hash. Later {@link #onChunk} calls reassemble. */
public static boolean isAwaiting(String hash) { public static boolean isAwaiting(String hash) {
return IN_FLIGHT.containsKey(hash); return IN_FLIGHT.containsKey(hash);
} }
@ -88,11 +111,18 @@ public final class ClientSkinCache {
} }
/** /**
* Consume one inbound {@code SkinChunk}. When the last chunk lands, writes to disk, * Consume one inbound deflated-pixel chunk. When the last chunk lands, inflates with a
* registers a {@link DynamicTexture}, and returns its synthetic {@link Identifier}. * bounded cap, writes the blob to disk, builds a {@link NativeImage} directly from pixels
* Returns {@code null} while more chunks are expected (or on error). * (no STB), and returns the synthetic {@link Identifier}. Returns {@code null} while more
* chunks are expected or on error.
*/ */
public static Identifier onChunk(String hash, int index, int total, byte[] data) { public static Identifier onChunk(String hash, int index, int total, int width, int height, byte[] data) {
if (width <= 0 || height <= 0 || width > SkinCache.MAX_DIM || height > SkinCache.MAX_DIM) {
DecoFashion.LOGGER.warn("Skin download {}: bad dims {}x{}", hash, width, height);
IN_FLIGHT.remove(hash);
return null;
}
Download d = IN_FLIGHT.get(hash); Download d = IN_FLIGHT.get(hash);
if (d == null) { if (d == null) {
d = new Download(); d = new Download();
@ -101,15 +131,17 @@ public final class ClientSkinCache {
if (index == 0) { if (index == 0) {
d.expectedTotal = total; d.expectedTotal = total;
d.nextIndex = 0; d.nextIndex = 0;
d.width = width;
d.height = height;
d.buffer.reset(); d.buffer.reset();
} else if (d.expectedTotal != total || d.nextIndex != index) { } else if (d.expectedTotal != total || d.nextIndex != index
DecoFashion.LOGGER.warn("Skin download {}: out-of-order chunk {}/{} (expected {}/{})", || d.width != width || d.height != height) {
hash, index, total, d.nextIndex, d.expectedTotal); DecoFashion.LOGGER.warn("Skin download {}: chunk mismatch at {}/{}", hash, index, total);
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
return null; return null;
} }
if (d.buffer.size() + data.length > SkinCache.MAX_BYTES) { if (d.buffer.size() + data.length > SkinCache.MAX_DEFLATED_BYTES) {
DecoFashion.LOGGER.warn("Skin download {}: exceeds size cap", hash); DecoFashion.LOGGER.warn("Skin download {}: exceeds deflated cap", hash);
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
return null; return null;
} }
@ -124,28 +156,63 @@ public final class ClientSkinCache {
if (index + 1 < total) return null; if (index + 1 < total) return null;
IN_FLIGHT.remove(hash); IN_FLIGHT.remove(hash);
byte[] png = d.buffer.toByteArray(); byte[] deflated = d.buffer.toByteArray();
byte[] rgba;
try { try {
Files.createDirectories(root()); rgba = SkinCache.inflateBounded(deflated, SkinCache.rgbaByteCount(d.width, d.height));
Files.write(fileFor(hash), png); } catch (IllegalArgumentException ex) {
DecoFashion.LOGGER.warn("Skin download {}: {}", hash, ex.getMessage());
return null;
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex); DecoFashion.LOGGER.warn("Skin download {}: inflate failed ({})", hash, ex.getMessage());
return null;
} }
try (InputStream in = new java.io.ByteArrayInputStream(png)) {
return register(hash, in); try {
Files.createDirectories(root());
Files.write(fileFor(hash), SkinCache.buildBlob(d.width, d.height, deflated));
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: image decode failed", hash, ex); DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex);
return null;
} }
return registerFromPixels(hash, d.width, d.height, rgba);
} }
private static Identifier register(String hash, InputStream in) throws IOException { /**
NativeImage img = NativeImage.read(in); * Build a {@link NativeImage} directly from RGBA bytes and register it. No PNG decoder
* is involved {@code NativeImage} is allocated in RGBA format and each pixel is written
* via {@code setPixelABGR}, where the int is packed so the native {@code memPutInt} lays
* down bytes in the order {@code [R, G, B, A]} (little-endian platforms).
*/
private static Identifier registerFromPixels(String hash, int width, int height, byte[] rgba) {
int expected;
try {
expected = SkinCache.rgbaByteCount(width, height);
} catch (IllegalArgumentException ex) {
DecoFashion.LOGGER.error("Skin {}: {}", hash, ex.getMessage());
return null;
}
if (rgba.length != expected) {
DecoFashion.LOGGER.error("Skin {}: pixel buffer length {} != {}*{}*4",
hash, rgba.length, width, height);
return null;
}
NativeImage img = new NativeImage(width, height, false);
int src = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int r = rgba[src++] & 0xFF;
int g = rgba[src++] & 0xFF;
int b = rgba[src++] & 0xFF;
int a = rgba[src++] & 0xFF;
img.setPixelABGR(x, y, r | (g << 8) | (b << 16) | (a << 24));
}
}
TextureManager tm = Minecraft.getInstance().getTextureManager(); TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier id = textureIdFor(hash); Identifier id = textureIdFor(hash);
tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img)); tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img));
REGISTERED.put(hash, id); REGISTERED.put(hash, id);
DecoFashion.LOGGER.info("Skin registered: hash={} id={}", hash, id); DecoFashion.LOGGER.info("Skin registered: hash={} id={} dims={}x{}", hash, id, width, height);
return id; return id;
} }
@ -154,21 +221,22 @@ public final class ClientSkinCache {
} }
/** /**
* Register every cached PNG under {@code <gameDir>/decofashion/skins_cache/}. Runs on * Register every cached blob under {@code <gameDir>/decofashion/skins_cache/}. Runs on
* client setup and wardrobe open so previously-uploaded skins (which persist as files) * client setup and wardrobe open so previously-uploaded skins reappear in the grid
* reappear in the grid without waiting for an attachment to name them. * without waiting for an attachment to name them. Filename must be a 64-char lowercase
* Filename must be a 64-char lowercase hex hash with {@code .png} suffix to be accepted. * hex hash with {@code .bin} suffix.
*/ */
public static void scanDisk() { public static void scanDisk() {
Path dir = root(); Path dir = root();
if (!Files.isDirectory(dir)) return; if (!Files.isDirectory(dir)) return;
int loaded = 0; int loaded = 0;
try (java.nio.file.DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.png")) { int expectedNameLen = 64 + SkinCache.FILE_EXT.length();
for (Path png : stream) { try (java.nio.file.DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*" + SkinCache.FILE_EXT)) {
String name = png.getFileName().toString(); for (Path bin : stream) {
if (name.length() != 68) continue; // 64 hex + ".png" String name = bin.getFileName().toString();
if (name.length() != expectedNameLen) continue;
String hash = name.substring(0, 64); String hash = name.substring(0, 64);
if (!hash.matches("[0-9a-f]+")) continue; if (!SkinCache.isValidHash(hash)) continue;
if (REGISTERED.containsKey(hash)) continue; if (REGISTERED.containsKey(hash)) continue;
if (ensureLoadedFromDisk(hash) != null) loaded++; if (ensureLoadedFromDisk(hash) != null) loaded++;
} }
@ -180,10 +248,10 @@ public final class ClientSkinCache {
/** /**
* Drop a cached skin from this client releases the texture, forgets the hashid map * Drop a cached skin from this client releases the texture, forgets the hashid map
* entry, and removes the on-disk PNG. The server's copy is untouched (other players * entry, and removes the on-disk blob. The server's copy is untouched.
* may still be using it). Returns true if anything was removed.
*/ */
public static boolean delete(String hash) { public static boolean delete(String hash) {
if (!SkinCache.isValidHash(hash)) return false;
Identifier id = REGISTERED.remove(hash); Identifier id = REGISTERED.remove(hash);
boolean changed = id != null; boolean changed = id != null;
if (id != null) { if (id != null) {
@ -203,64 +271,111 @@ public final class ClientSkinCache {
} }
/** /**
* Read a PNG from disk, validate, chunk it, and ship the chunks upstream via * Read a PNG from disk, decode it with {@link SafePngReader} (pure Java, no STB),
* {@link UploadSkinChunk}. Also caches the PNG locally (same hash the server will compute) * deflate the raw RGBA, chunk it, and ship chunks upstream as {@link UploadSkinChunk}s.
* so the wardrobe grid can show it immediately without waiting for the server round-trip. * Also caches locally under the same hash the server will compute so the wardrobe shows
* Returns a short user-facing status string for display. * it immediately. Returns a short user-facing status string.
*/ */
public static String uploadFromFile(Path file, SkinModel model) { public static String uploadFromFile(Path file, SkinModel model) {
String result = doUpload(file, model);
DecoFashion.LOGGER.info("Skin upload: {}", result);
return result;
}
private static String doUpload(Path file, SkinModel model) {
if (!Files.isRegularFile(file)) return "Not a file: " + file; if (!Files.isRegularFile(file)) return "Not a file: " + file;
byte[] png; byte[] png;
try { try {
long size = Files.size(file);
if (size > SafeImageLoader.MAX_PNG_FILE_BYTES) {
return "PNG file too large: " + size;
}
png = Files.readAllBytes(file); png = Files.readAllBytes(file);
} catch (IOException ex) { } catch (IOException ex) {
return "Read failed: " + ex.getMessage(); return "Read failed: " + ex.getMessage();
} }
if (png.length > SkinCache.MAX_BYTES) {
return "Too large: " + png.length + " > " + SkinCache.MAX_BYTES; SafePngReader.Image decoded;
try {
decoded = SafePngReader.decode(png);
} catch (IOException ex) {
return "Rejected: " + ex.getMessage();
} }
if (png.length < 8
|| png[0] != (byte) 0x89 || png[1] != 'P' || png[2] != 'N' || png[3] != 'G') { byte[] deflated = deflate(decoded.rgba);
return "Not a PNG"; if (deflated.length > SkinCache.MAX_DEFLATED_BYTES) {
return "Too large after compression: " + deflated.length;
} }
ClientPacketListener conn = Minecraft.getInstance().getConnection(); ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return "Not connected"; if (conn == null) return "Not connected";
// Local-cache first so the grid picks it up without waiting for the server echo. String localHash;
String localHash = null; try {
localHash = sha256HexOfPixels(decoded.width, decoded.height, decoded.rgba);
} catch (NoSuchAlgorithmException ex) {
return "SHA-256 unavailable";
}
try { try {
localHash = sha256Hex(png);
Files.createDirectories(root()); Files.createDirectories(root());
if (!hasOnDisk(localHash)) Files.write(fileFor(localHash), png); if (!hasOnDisk(localHash)) {
ensureLoadedFromDisk(localHash); Files.write(fileFor(localHash), SkinCache.buildBlob(decoded.width, decoded.height, deflated));
} catch (Exception ex) { }
if (!REGISTERED.containsKey(localHash)) {
registerFromPixels(localHash, decoded.width, decoded.height, decoded.rgba);
}
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage()); DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage());
} }
int chunk = SkinCache.MAX_CHUNK_BYTES; int chunk = SkinCache.MAX_CHUNK_BYTES;
int total = Math.max(1, (png.length + chunk - 1) / chunk); int total = Math.max(1, (deflated.length + chunk - 1) / chunk);
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
int off = i * chunk; int off = i * chunk;
int len = Math.min(chunk, png.length - off); int len = Math.min(chunk, deflated.length - off);
byte[] slice = new byte[len]; byte[] slice = new byte[len];
System.arraycopy(png, off, slice, 0, len); System.arraycopy(deflated, off, slice, 0, len);
conn.send(new ServerboundCustomPayloadPacket(new UploadSkinChunk(i, total, model, slice))); conn.send(new ServerboundCustomPayloadPacket(
new UploadSkinChunk(i, total, decoded.width, decoded.height, model, slice)));
}
return "Uploading " + decoded.width + "x" + decoded.height + " ("
+ deflated.length + " bytes, " + total + " chunks, hash "
+ localHash.substring(0, 8) + ")";
}
private static byte[] deflate(byte[] raw) {
Deflater def = new Deflater(Deflater.BEST_COMPRESSION);
try {
def.setInput(raw);
def.finish();
ByteArrayOutputStream out = new ByteArrayOutputStream(raw.length / 2 + 64);
byte[] buf = new byte[64 * 1024];
while (!def.finished()) {
int n = def.deflate(buf);
if (n == 0) break;
out.write(buf, 0, n);
}
return out.toByteArray();
} finally {
def.end();
} }
return "Uploading " + png.length + " bytes in " + total + " chunks"
+ (localHash != null ? " (hash " + localHash.substring(0, 8) + ")" : "");
} }
private static String sha256Hex(byte[] in) throws java.security.NoSuchAlgorithmException { private static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] out = md.digest(in); md.update((byte) ((width >>> 8) & 0xFF));
md.update((byte) (width & 0xFF));
md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 0xFF));
md.update(rgba);
byte[] out = md.digest();
StringBuilder sb = new StringBuilder(out.length * 2); StringBuilder sb = new StringBuilder(out.length * 2);
for (byte b : out) sb.append(String.format("%02x", b)); for (byte b : out) sb.append(String.format("%02x", b));
return sb.toString(); return sb.toString();
} }
/** Shortcut: send any server-bound payload from the client. */
public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) { public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) {
ClientPacketListener conn = Minecraft.getInstance().getConnection(); ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return; if (conn == null) return;

@ -61,7 +61,7 @@ public class ClosetRenderer implements BlockEntityRenderer<ClosetBlockEntity, Cl
collector.submitModelPart( collector.submitModelPart(
part, part,
poseStack, poseStack,
RenderTypes.entityCutout(baked.texture()), RenderTypes.entityTranslucent(baked.texture()),
state.lightCoords, state.lightCoords,
OverlayTexture.NO_OVERLAY, OverlayTexture.NO_OVERLAY,
null null

@ -2,6 +2,7 @@ package com.razz.dfashion.client;
import com.mojang.blaze3d.vertex.PoseStack; import com.mojang.blaze3d.vertex.PoseStack;
import com.razz.dfashion.cosmetic.CosmeticAttachments; import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.cosmetic.CosmeticRef;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.model.geom.ModelPart; import net.minecraft.client.model.geom.ModelPart;
@ -33,7 +34,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
* falls back to the live entity's {@link CosmeticAttachments#EQUIPPED} attachment. * falls back to the live entity's {@link CosmeticAttachments#EQUIPPED} attachment.
* Use {@link IdentityHashMap} semantics so collisions can't happen across states. * Use {@link IdentityHashMap} semantics so collisions can't happen across states.
*/ */
public static final Map<AvatarRenderState, Map<String, net.minecraft.resources.Identifier>> RENDER_OVERRIDES = public static final Map<AvatarRenderState, Map<String, CosmeticRef>> RENDER_OVERRIDES =
Collections.synchronizedMap(new IdentityHashMap<>()); Collections.synchronizedMap(new IdentityHashMap<>());
public CosmeticRenderLayer(RenderLayerParent<AvatarRenderState, PlayerModel> parent) { public CosmeticRenderLayer(RenderLayerParent<AvatarRenderState, PlayerModel> parent) {
@ -44,14 +45,16 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords, public void submit(PoseStack poseStack, SubmitNodeCollector collector, int lightCoords,
AvatarRenderState state, float yRot, float xRot) { AvatarRenderState state, float yRot, float xRot) {
// Local cache may be empty (no built-in / user-folder cosmetics loaded) yet the player
// can still have Shared refs that resolve through ClientSharedCosmeticCache. Don't
// early-return on local emptiness.
Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics; Map<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
if (cache.isEmpty()) return;
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
// GUI preview override takes precedence: the wardrobe Screen sets this to display // GUI preview override takes precedence: the wardrobe Screen sets this to display
// a specific cosmetic in a mini-player box without touching the live attachment. // a specific cosmetic in a mini-player box without touching the live attachment.
Map<String, Identifier> equipped = RENDER_OVERRIDES.get(state); Map<String, CosmeticRef> equipped = RENDER_OVERRIDES.get(state);
if (equipped == null) { if (equipped == null) {
if (mc.level == null) return; if (mc.level == null) return;
Entity entity = mc.level.getEntity(state.id); Entity entity = mc.level.getEntity(state.id);
@ -62,9 +65,9 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
PlayerModel model = getParentModel(); PlayerModel model = getParentModel();
for (Identifier cosmeticId : equipped.values()) { for (CosmeticRef ref : equipped.values()) {
CosmeticCache.Baked cosmetic = cache.get(cosmeticId); CosmeticCache.Baked cosmetic = resolve(ref, cache);
if (cosmetic == null) continue; // unknown cosmetic id, skip if (cosmetic == null) continue; // unknown / not yet downloaded
for (Map.Entry<String, ModelPart> entry : cosmetic.parts().entrySet()) { for (Map.Entry<String, ModelPart> entry : cosmetic.parts().entrySet()) {
ModelPart bone = findBone(model, entry.getKey()); ModelPart bone = findBone(model, entry.getKey());
@ -78,7 +81,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
collector.submitModelPart( collector.submitModelPart(
entry.getValue(), entry.getValue(),
poseStack, poseStack,
RenderTypes.entityCutout(cosmetic.texture()), RenderTypes.entityTranslucent(cosmetic.texture()),
lightCoords, lightCoords,
OverlayTexture.NO_OVERLAY, OverlayTexture.NO_OVERLAY,
null null
@ -88,6 +91,19 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
} }
} }
private static CosmeticCache.Baked resolve(
CosmeticRef ref, Map<Identifier, CosmeticCache.Baked> localCache
) {
return switch (ref) {
case CosmeticRef.Local l -> localCache.get(l.id());
case CosmeticRef.Shared s -> {
CosmeticCache.Baked baked = ClientSharedCosmeticCache.getBaked(s.hash());
if (baked == null) ClientSharedCosmeticCache.requestIfMissing(s.hash());
yield baked;
}
};
}
private static ModelPart findBone(PlayerModel model, String rawName) { private static ModelPart findBone(PlayerModel model, String rawName) {
String name = normalize(rawName); String name = normalize(rawName);
ModelPart bone = switch (name) { ModelPart bone = switch (name) {

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

@ -1,16 +1,28 @@
package com.razz.dfashion.client.screen; package com.razz.dfashion.client.screen;
import com.razz.dfashion.block.ClosetBlockEntity; import com.razz.dfashion.block.ClosetBlockEntity;
import com.razz.dfashion.client.ClientSharedCosmeticCache;
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.ClientSkinCache;
import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticCache;
import com.razz.dfashion.client.CosmeticRenderLayer; import com.razz.dfashion.client.CosmeticRenderLayer;
import com.razz.dfashion.client.SkinInfoOverride; import com.razz.dfashion.client.SkinInfoOverride;
import com.razz.dfashion.cosmetic.CosmeticAttachments; import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.cosmetic.CosmeticDefinition; import com.razz.dfashion.cosmetic.CosmeticDefinition;
import com.razz.dfashion.cosmetic.CosmeticRef;
import com.razz.dfashion.cosmetic.share.BoneExtraction;
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
import com.razz.dfashion.cosmetic.share.CosmeticLibraryEntry;
import com.razz.dfashion.cosmetic.share.packet.AssignSharedCosmetic;
import com.razz.dfashion.cosmetic.share.packet.DeleteCosmetic;
import com.razz.dfashion.skin.SkinAttachments; import com.razz.dfashion.skin.SkinAttachments;
import com.razz.dfashion.skin.SkinData; import com.razz.dfashion.skin.SkinData;
import com.razz.dfashion.skin.SkinLibrary;
import com.razz.dfashion.skin.SkinLibraryEntry;
import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.SkinModel;
import com.razz.dfashion.skin.packet.AssignSkin; import com.razz.dfashion.skin.packet.AssignSkin;
import com.razz.dfashion.skin.packet.DeleteSkin;
import com.razz.dfashion.skin.packet.RequestSkin;
import net.minecraft.core.ClientAsset; import net.minecraft.core.ClientAsset;
import net.minecraft.world.entity.player.PlayerModelType; import net.minecraft.world.entity.player.PlayerModelType;
@ -21,6 +33,8 @@ import net.minecraft.client.CameraType;
import net.minecraft.client.Minecraft; import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.components.Button;
import net.minecraft.client.gui.components.EditBox;
import net.minecraft.client.gui.components.StringWidget;
import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.player.LocalPlayer; import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.entity.EntityRenderDispatcher; import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
@ -45,19 +59,37 @@ import org.joml.Quaternionf;
import org.joml.Vector3f; import org.joml.Vector3f;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
public class ClosetScreen extends Screen { public class ClosetScreen extends Screen {
// Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the // Tab vocabulary — matches category memory. "skin" is a pseudo-category that drives the
// per-player skin override UI (upload/reset) rather than the usual cosmetic row. // per-player skin override UI (upload/reset) rather than the usual cosmetic row.
// "shared" is a pseudo-category for the shared-cosmetic library (vertical scroll grid).
private static final String[] CATEGORIES = { private static final String[] CATEGORIES = {
"hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin" "hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin", "shared"
}; };
private static final String SKIN_TAB = "skin"; private static final String SKIN_TAB = "skin";
private static final String SHARED_TAB = "shared";
/** Fallback equip category used when a shared cosmetic has no canonical bones. */
private static final String FALLBACK_SHARED_CATEGORY = "particle";
/**
* Bone-based filters on the shared tab. {@code null} = "All"; others are canonical
* bone group names used by {@link BoneExtraction}. Labels are user-facing ({@code Torso}
* rather than the canonical {@code Body}).
*/
private static final String[] SHARED_FILTER_KEYS = { null, "Head", "Body", "Arm", "Leg" };
private static final String[] SHARED_FILTER_LABELS = { "All", "Head", "Torso", "Arms", "Legs" };
// Per-tick rotation rate while an arrow is held (20 tps → 120°/sec). // Per-tick rotation rate while an arrow is held (20 tps → 120°/sec).
private static final float ROTATE_PER_TICK = 6f; private static final float ROTATE_PER_TICK = 6f;
@ -76,6 +108,7 @@ public class ClosetScreen extends Screen {
private static final int COSMETIC_INNER_PADDING = 3; private static final int COSMETIC_INNER_PADDING = 3;
private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview private static final int COSMETIC_BORDER_COLOR = 0xFFFFFFFF; // normal frame around each preview
private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn private static final int COSMETIC_BORDER_EQUIPPED_COLOR = 0xFF55FF55; // green frame when this item is currently worn
private static final int COSMETIC_BORDER_SHARED_COLOR = 0xFFFFB060; // amber frame — entry came from the user's shared library
private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette private static final float COSMETIC_PREVIEW_SIZE = 11f; // entity render scale — small enough to fit cosmetics that extend the silhouette
private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility private static final float COSMETIC_PREVIEW_YAW = -20f; // degrees — angled for better cosmetic visibility
@ -117,6 +150,40 @@ public class ClosetScreen extends Screen {
private double dragLastX = 0; private double dragLastX = 0;
private boolean isDraggingRow = false; private boolean isDraggingRow = false;
// ---- Share-cosmetic form state ----
// When showingShareForm is true, init() replaces the normal tabs/grid/buttons with a
// modal form. Widget refs are nullable because they're rebuilt on every widget refresh
// (Screen.rebuildWidgets clears children and re-runs init).
private boolean showingShareForm = false;
private @Nullable EditBox shareTextureField;
private @Nullable EditBox shareModelField;
private @Nullable EditBox shareNameField;
private @Nullable StringWidget shareStatusLabel;
private String shareStatusMessage = "";
/** UX cap on displayName shown in the field. Wire codec caps at 128; 64 is user-friendly. */
private static final int SHARE_NAME_MAX_LEN = 64;
/** OS path-length slack. Real paths rarely exceed 4 KB; inputs past this are suspect. */
private static final int SHARE_PATH_MAX_LEN = 4096;
// ---- Shared-library tab state ----
/** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */
private @Nullable String sharedBoneFilter = null;
private int sharedVerticalScroll = 0;
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
private int lastKnownSharedCount = -1;
/** Filter bar Y (below the tab row). */
private static final int SHARED_FILTER_Y = TAB_Y + TAB_H + 10;
private static final int SHARED_FILTER_H = 20;
private static final int SHARED_FILTER_BTN_W = 50;
private static final int SHARED_FILTER_BTN_GAP = 4;
/** Top of the vertical-scroll grid on the Shared tab (below the filter bar). */
private static final int SHARED_GRID_TOP = SHARED_FILTER_Y + SHARED_FILTER_H + 10;
/** Height reserved for bottom buttons; the grid stops above this. */
private static final int SHARED_GRID_BOTTOM_PAD = 50;
public ClosetScreen(@Nullable ClosetBlockEntity closet) { public ClosetScreen(@Nullable ClosetBlockEntity closet) {
super(Component.literal("Wardrobe")); super(Component.literal("Wardrobe"));
this.closet = closet; this.closet = closet;
@ -147,6 +214,11 @@ public class ClosetScreen extends Screen {
ClientSkinCache.scanDisk(); ClientSkinCache.scanDisk();
} }
if (showingShareForm) {
buildShareForm();
return;
}
buildTabs(); buildTabs();
buildBottomControls(); buildBottomControls();
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]); selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
@ -155,7 +227,7 @@ public class ClosetScreen extends Screen {
private void buildTabs() { private void buildTabs() {
int x = TAB_X_START; int x = TAB_X_START;
for (String cat : CATEGORIES) { for (String cat : CATEGORIES) {
String label = cat.substring(0, 1).toUpperCase(); String label = tabLabel(cat);
addRenderableWidget(Button.builder( addRenderableWidget(Button.builder(
Component.literal(label), Component.literal(label),
b -> selectCategory(cat) b -> selectCategory(cat)
@ -164,6 +236,14 @@ public class ClosetScreen extends Screen {
} }
} }
/** Single-char tab labels; {@code shared} overridden to "L" so it doesn't collide with {@code skin}. */
private static String tabLabel(String cat) {
return switch (cat) {
case SHARED_TAB -> "L";
default -> cat.substring(0, 1).toUpperCase();
};
}
private void buildBottomControls() { private void buildBottomControls() {
int centerX = this.width / 2; int centerX = this.width / 2;
int y = this.height - 40; int y = this.height - 40;
@ -190,6 +270,14 @@ public class ClosetScreen extends Screen {
rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons rebuildCosmeticRow(); // re-apply visibility to the cosmetic buttons
} }
).bounds(this.width - 70, y, 60, 20).build()); ).bounds(this.width - 70, y, 60, 20).build());
// Open the shared-cosmetic upload form. All validation + parsing happens in the
// uploader's pipeline (SafePngReader + BbModelParser + BbmodelCodec); this button
// just swaps the closet UI into the form's widgets.
addRenderableWidget(Button.builder(
Component.literal("Share"),
b -> openShareForm()
).bounds(this.width - 140, y, 60, 20).build());
} }
private void selectCategory(String cat) { private void selectCategory(String cat) {
@ -202,16 +290,22 @@ public class ClosetScreen extends Screen {
private void rebuildCosmeticRow() { private void rebuildCosmeticRow() {
for (Button btn : cosmeticButtons) removeWidget(btn); for (Button btn : cosmeticButtons) removeWidget(btn);
cosmeticButtons.clear(); cosmeticButtons.clear();
sharedButtonHashes.clear();
if (selectedCategory == null) return; if (selectedCategory == null) return;
if (SKIN_TAB.equals(selectedCategory)) { if (SKIN_TAB.equals(selectedCategory)) {
buildSkinTabRow(); buildSkinTabRow();
return; return;
} }
if (SHARED_TAB.equals(selectedCategory)) {
buildSharedTabRow();
return;
}
int x = COSMETIC_ROW_LEFT - cosmeticScroll; int x = COSMETIC_ROW_LEFT - cosmeticScroll;
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN; int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Authored cosmetics for this category.
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) { for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue(); CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue; if (!selectedCategory.equals(def.category())) continue;
@ -234,6 +328,32 @@ public class ClosetScreen extends Screen {
addRenderableWidget(btn); addRenderableWidget(btn);
x += COSMETIC_SIZE + COSMETIC_SPACING; x += COSMETIC_SIZE + COSMETIC_SPACING;
} }
// Unified browse: append shared-library entries whose bones map to this category.
// Same box size + stride as authored — they scroll together in the same horizontal
// row. Visual distinguisher is a different border color in the preview renderer.
LocalPlayer player = Minecraft.getInstance().player;
CosmeticLibrary lib = player != null
? player.getData(CosmeticAttachments.SHARED_LIBRARY.get())
: CosmeticLibrary.EMPTY;
for (CosmeticLibraryEntry libEntry : lib.entries()) {
if (!BoneExtraction.categoriesFor(libEntry.bones()).contains(selectedCategory)) continue;
// Pull from server if the blob hasn't materialized locally yet.
ClientSharedCosmeticCache.requestIfMissing(libEntry.hash());
Button btn = Button.builder(
Component.empty(),
b -> equipShared(libEntry)
).bounds(x, y, COSMETIC_SIZE, COSMETIC_SIZE).build();
btn.visible = showPreviews && (x >= COSMETIC_ROW_LEFT) && (x < this.width);
btn.active = btn.visible;
cosmeticButtons.add(btn);
addRenderableWidget(btn);
sharedButtonHashes.put(btn, libEntry.hash());
x += COSMETIC_SIZE + COSMETIC_SPACING;
}
} }
// Skin tab layout — action buttons stack vertically on the left, grid runs right. // Skin tab layout — action buttons stack vertically on the left, grid runs right.
@ -295,10 +415,22 @@ public class ClosetScreen extends Screen {
cosmeticButtons.add(reset); cosmeticButtons.add(reset);
// Grid row starts past the action column. Boxes honor the Toggle button; action // Grid row starts past the action column. Boxes honor the Toggle button; action
// column above stays visible either way. // column above stays visible either way. Source of truth is the per-player server
// library attachment — not the local blob cache — so other players' uploads never
// appear here even when their blobs happen to be on disk.
int boxX = SKIN_GRID_START_X - cosmeticScroll; int boxX = SKIN_GRID_START_X - cosmeticScroll;
for (Map.Entry<String, Identifier> entry : ClientSkinCache.snapshotRegistered().entrySet()) { LocalPlayer localPlayer = Minecraft.getInstance().player;
final String hash = entry.getKey(); SkinLibrary library = localPlayer != null
? localPlayer.getData(SkinAttachments.LIBRARY.get())
: SkinLibrary.EMPTY;
for (SkinLibraryEntry libEntry : library.entries()) {
final String hash = libEntry.hash();
// Pull bytes from the server if this client hasn't seen them yet.
if (ClientSkinCache.ensureLoadedFromDisk(hash) == null
&& !ClientSkinCache.isAwaiting(hash)) {
ClientSkinCache.markRequested(hash);
ClientSkinCache.sendToServer(new RequestSkin(hash));
}
boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width); boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width);
boolean show = showPreviews && onScreen; boolean show = showPreviews && onScreen;
@ -328,6 +460,187 @@ public class ClosetScreen extends Screen {
} }
} }
// ---- Shared-library tab (vertical scroll grid) ----
/**
* Shared tab layout: horizontal filter row at top (All / Head / Torso / Arms / Legs),
* wrapping grid below using full width. Clicking a filter narrows the grid to entries
* whose bone set includes the filter's bone group. Entries with only {@link
* BoneExtraction#OTHER} bones show only under "All".
*/
private void buildSharedTabRow() {
sharedButtonHashes.clear();
// Filter bar — one button per filter option; active filter is dimmed so the player
// can tell which one is applied without hover state.
int fx = COSMETIC_ROW_LEFT;
for (int i = 0; i < SHARED_FILTER_KEYS.length; i++) {
final String key = SHARED_FILTER_KEYS[i];
Button filterBtn = Button.builder(
Component.literal(SHARED_FILTER_LABELS[i]),
b -> setSharedBoneFilter(key)
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
filterBtn.active = !java.util.Objects.equals(sharedBoneFilter, key);
addRenderableWidget(filterBtn);
cosmeticButtons.add(filterBtn);
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
}
// Grid area — full-width now that the slot selector is gone.
int gridY = SHARED_GRID_TOP;
int gridLeft = COSMETIC_ROW_LEFT;
int gridRight = this.width - 10;
int gridBottom = this.height - SHARED_GRID_BOTTOM_PAD;
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride);
LocalPlayer player = Minecraft.getInstance().player;
CosmeticLibrary lib = player != null
? player.getData(CosmeticAttachments.SHARED_LIBRARY.get())
: CosmeticLibrary.EMPTY;
int idx = 0;
for (CosmeticLibraryEntry libEntry : lib.entries()) {
if (!entryMatchesSharedFilter(libEntry)) continue;
String hash = libEntry.hash();
// Pull from server if the blob hasn't materialized locally yet.
ClientSharedCosmeticCache.requestIfMissing(hash);
int row = idx / boxesPerRow;
int col = idx % boxesPerRow;
int boxX = gridLeft + col * stride;
int boxY = gridY + row * stride - sharedVerticalScroll;
idx++;
boolean onScreen = boxY + COSMETIC_SIZE > gridY && boxY < gridBottom;
boolean show = showPreviews && onScreen;
Button pick = Button.builder(
Component.empty(),
b -> equipShared(libEntry)
).bounds(boxX, boxY, COSMETIC_SIZE, COSMETIC_SIZE).build();
pick.visible = show;
pick.active = show;
cosmeticButtons.add(pick);
addRenderableWidget(pick);
sharedButtonHashes.put(pick, hash);
Button del = Button.builder(
Component.literal("x"),
b -> deleteShared(hash)
).bounds(boxX + COSMETIC_SIZE - 12, boxY - 12, 12, 12).build();
del.visible = show;
del.active = show;
cosmeticButtons.add(del);
addRenderableWidget(del);
}
}
private void setSharedBoneFilter(@Nullable String filter) {
sharedBoneFilter = filter;
sharedVerticalScroll = 0;
rebuildCosmeticRow();
}
/**
* Does an entry's bone set include the currently-selected filter's group? Maps
* filter keys to canonical bones: {@code "Head"} just {@code Head};
* {@code "Body"} {@code Body}; {@code "Arm"} either left/right arm;
* {@code "Leg"} either left/right leg. {@code null} filter always matches.
*/
private boolean entryMatchesSharedFilter(CosmeticLibraryEntry entry) {
if (sharedBoneFilter == null) return true;
List<String> bones = entry.bones();
return switch (sharedBoneFilter) {
case "Head" -> bones.contains(BoneExtraction.HEAD);
case "Body" -> bones.contains(BoneExtraction.BODY);
case "Arm" -> bones.contains(BoneExtraction.LEFT_ARM) || bones.contains(BoneExtraction.RIGHT_ARM);
case "Leg" -> bones.contains(BoneExtraction.LEFT_LEG) || bones.contains(BoneExtraction.RIGHT_LEG);
default -> false;
};
}
/**
* Derive the equip category from the cosmetic's primary bone and toggle equip in
* that slot matches the authored path's click-to-unequip behavior. An empty hash
* on the wire tells the server to clear the slot (see
* {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork#onAssignShared}).
*/
private void equipShared(CosmeticLibraryEntry entry) {
String category = BoneExtraction.primaryCategory(entry.bones());
if (category == null) category = FALLBACK_SHARED_CATEGORY;
LocalPlayer player = Minecraft.getInstance().player;
Map<String, CosmeticRef> currentEquipped = player != null
? player.getData(CosmeticAttachments.EQUIPPED.get())
: Map.of();
CosmeticRef current = currentEquipped.get(category);
boolean alreadyEquipped = current instanceof CosmeticRef.Shared s
&& entry.hash().equals(s.hash());
String hashOnWire = alreadyEquipped ? "" : entry.hash();
ClientSharedCosmeticCache.sendToServer(new AssignSharedCosmetic(category, hashOnWire));
}
/**
* Look up the library entry for a given content hash. O(N) over library entries
* fine since {@link CosmeticLibrary#MAX_ENTRIES} caps this at a small number.
*/
private @Nullable CosmeticLibraryEntry findSharedEntry(String hash) {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return null;
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
for (CosmeticLibraryEntry e : lib.entries()) {
if (e.hash().equals(hash)) return e;
}
return null;
}
/** Same derivation used by {@link #equipShared}, looked up by hash — for preview rendering. */
private String sharedCategoryForHash(String hash) {
CosmeticLibraryEntry entry = findSharedEntry(hash);
if (entry == null) return FALLBACK_SHARED_CATEGORY;
String cat = BoneExtraction.primaryCategory(entry.bones());
return cat != null ? cat : FALLBACK_SHARED_CATEGORY;
}
private void deleteShared(String hash) {
// Server drops the hash from this player's library; the local call releases the
// baked model + texture and deletes the local blob cache file.
ClientSharedCosmeticCache.sendToServer(new DeleteCosmetic(hash));
ClientSharedCosmeticCache.delete(hash);
rebuildCosmeticRow();
}
/** Total vertical extent of the shared grid, counting only entries that pass the filter. */
private int sharedGridContentHeight() {
LocalPlayer player = Minecraft.getInstance().player;
if (player == null) return 0;
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
int stride = COSMETIC_SIZE + COSMETIC_SPACING;
int gridLeft = COSMETIC_ROW_LEFT;
int gridRight = this.width - 10;
int boxesPerRow = Math.max(1, (gridRight - gridLeft + COSMETIC_SPACING) / stride);
int visible = 0;
for (CosmeticLibraryEntry e : lib.entries()) {
if (entryMatchesSharedFilter(e)) visible++;
}
int rows = (visible + boxesPerRow - 1) / boxesPerRow;
return rows * stride;
}
private void clampSharedScroll() {
int viewport = (this.height - SHARED_GRID_BOTTOM_PAD) - SHARED_GRID_TOP;
int max = Math.max(0, sharedGridContentHeight() - viewport);
if (sharedVerticalScroll < 0) sharedVerticalScroll = 0;
if (sharedVerticalScroll > max) sharedVerticalScroll = max;
}
private static String titleCaseAscii(String s) {
if (s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
/** /**
* Apply a cached skin locally + tell the server. Also syncs {@link SkinInfoOverride} * Apply a cached skin locally + tell the server. Also syncs {@link SkinInfoOverride}
* immediately so the dispatcher picks the right renderer this frame, not next tick. * immediately so the dispatcher picks the right renderer this frame, not next tick.
@ -341,22 +654,24 @@ public class ClosetScreen extends Screen {
} }
private void deleteCachedSkin(String hash) { private void deleteCachedSkin(String hash) {
// Tell the server to drop it from this player's library (and reset their active
// SkinData if they were wearing it). The server broadcasts nothing to other
// players beyond the SkinData reset — other players' libraries aren't touched.
ClientSkinCache.sendToServer(new DeleteSkin(hash));
ClientSkinCache.delete(hash); ClientSkinCache.delete(hash);
// If the player was wearing this skin, reset both sides of the sync:
// - send AssignSkin("") so other clients + the server record the reset
// - setData(EMPTY) locally so the SkinInfoOverride tick doesn't re-request the
// bytes before the server sync round-trips back (which would re-cache the
// skin we just deleted and require a second click to actually get rid of it).
LocalPlayer player = Minecraft.getInstance().player; LocalPlayer player = Minecraft.getInstance().player;
if (player != null) { if (player != null) {
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
player.setData(SkinAttachments.LIBRARY.get(), lib.remove(hash));
SkinData current = player.getData(SkinAttachments.DATA.get()); SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && hash.equals(current.hash())) { if (current != null && hash.equals(current.hash())) {
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY); player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
SkinInfoOverride.syncAll(); SkinInfoOverride.syncAll();
} }
} }
lastKnownSkinCount = -1; // force the skin-count poll to re-pick up the change lastKnownSkinCount = -1;
rebuildCosmeticRow(); rebuildCosmeticRow();
} }
@ -394,14 +709,244 @@ public class ClosetScreen extends Screen {
} }
} }
// ---- Shared-cosmetic upload form ----
private void openShareForm() {
showingShareForm = true;
rebuildWidgets();
}
private void closeShareForm() {
showingShareForm = false;
shareTextureField = null;
shareModelField = null;
shareNameField = null;
shareStatusLabel = null;
shareStatusMessage = "";
rebuildWidgets();
}
/**
* Build the modal form. EditBox contents are plain user text every byte is re-validated
* by {@link #submitShareForm} before anything reaches the uploader, and the uploader
* itself does the real sanitization (file-size gates, {@code SafePngReader},
* {@code BbModelParser}, {@code BbmodelCodec}). This UI layer just provides friendly
* early rejection so the user sees a clear error before the upload attempt.
*/
private void buildShareForm() {
int panelW = Math.min(360, this.width - 40);
int cx = this.width / 2;
int x0 = cx - panelW / 2;
int fieldW = panelW - 20;
int fieldH = 20;
int rowGap = 10;
int y = this.height / 2 - 90;
StringWidget title = new StringWidget(
x0, y, panelW, 15,
Component.literal("Share Cosmetic"),
this.font
);
addRenderableWidget(title);
y += 22;
// Texture path row: EditBox + Browse.
int browseW = 60;
shareTextureField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
Component.literal("Texture path"));
shareTextureField.setMaxLength(SHARE_PATH_MAX_LEN);
shareTextureField.setHint(Component.literal("Path to .png"));
addRenderableWidget(shareTextureField);
addRenderableWidget(Button.builder(
Component.literal("Browse"),
b -> pickShareTexturePath()
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
y += fieldH + rowGap;
// Model path row: EditBox + Browse.
shareModelField = new EditBox(this.font, x0 + 10, y, fieldW - browseW - 5, fieldH,
Component.literal("Model path"));
shareModelField.setMaxLength(SHARE_PATH_MAX_LEN);
shareModelField.setHint(Component.literal("Path to .bbmodel"));
addRenderableWidget(shareModelField);
addRenderableWidget(Button.builder(
Component.literal("Browse"),
b -> pickShareModelPath()
).bounds(x0 + 10 + fieldW - browseW, y, browseW, fieldH).build());
y += fieldH + rowGap;
// Name (optional).
shareNameField = new EditBox(this.font, x0 + 10, y, fieldW, fieldH,
Component.literal("Display name"));
shareNameField.setMaxLength(SHARE_NAME_MAX_LEN);
shareNameField.setHint(Component.literal("Name (optional — defaults to texture filename)"));
addRenderableWidget(shareNameField);
y += fieldH + rowGap;
// Status line (updated after submit).
shareStatusLabel = new StringWidget(
x0 + 10, y, fieldW, 15,
Component.literal(shareStatusMessage),
this.font
);
addRenderableWidget(shareStatusLabel);
y += 22;
// Submit / Cancel.
int btnW = (fieldW - 10) / 2;
addRenderableWidget(Button.builder(
Component.literal("Submit"),
b -> submitShareForm()
).bounds(x0 + 10, y, btnW, fieldH).build());
addRenderableWidget(Button.builder(
Component.literal("Cancel"),
b -> closeShareForm()
).bounds(x0 + 10 + btnW + 10, y, btnW, fieldH).build());
}
private void pickShareTexturePath() {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.png"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select texture PNG", "", filters, "PNG images", false);
if (path != null && !path.isEmpty() && shareTextureField != null) {
shareTextureField.setValue(path);
}
}
}
private void pickShareModelPath() {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.bbmodel"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select .bbmodel", "", filters, "bbmodel files", false);
if (path != null && !path.isEmpty() && shareModelField != null) {
shareModelField.setValue(path);
}
}
}
/**
* Sanitize every field, reject obvious bad input early with clear messages, and then
* hand off to {@link ClientSharedCosmeticUploader} which runs the actual security
* pipeline (file-size gates, {@code SafePngReader}, {@code BbModelParser},
* {@code BbmodelCodec}, library-dedup check, chunking).
*/
private void submitShareForm() {
if (shareTextureField == null || shareModelField == null || shareNameField == null) return;
String texturePath = sanitizeSharePath(shareTextureField.getValue());
String modelPath = sanitizeSharePath(shareModelField.getValue());
String name = sanitizeShareName(shareNameField.getValue());
if (texturePath.isEmpty()) { setShareStatus("Texture path required"); return; }
if (modelPath.isEmpty()) { setShareStatus("Model path required"); return; }
Path texture;
Path model;
try {
texture = Paths.get(texturePath);
model = Paths.get(modelPath);
} catch (InvalidPathException ex) {
setShareStatus("Invalid path: " + ex.getMessage());
return;
}
if (!Files.isRegularFile(texture)) { setShareStatus("Texture file not found"); return; }
if (!Files.isRegularFile(model)) { setShareStatus("Model file not found"); return; }
String texLower = texturePath.toLowerCase(Locale.ROOT);
String mdlLower = modelPath.toLowerCase(Locale.ROOT);
if (!texLower.endsWith(".png")) { setShareStatus("Texture must be .png"); return; }
if (!mdlLower.endsWith(".bbmodel")) { setShareStatus("Model must be .bbmodel"); return; }
if (name.isEmpty()) name = titleCaseFilenameStem(texture.getFileName().toString());
String result = ClientSharedCosmeticUploader.upload(model, texture, name);
setShareStatus(result);
// Clear the fields on successful submission so a second upload starts fresh.
// The uploader's status string is the only signal of outcome — "Uploading " means
// chunks went to the server, "Already in library" means dedup short-circuited
// (still a valid state, nothing to retry). Failures keep the fields populated so
// the user can correct and resubmit without re-entering paths.
if (result != null
&& (result.startsWith("Uploading ") || result.startsWith("Already in library"))) {
shareTextureField.setValue("");
shareModelField.setValue("");
shareNameField.setValue("");
}
}
private void setShareStatus(String msg) {
shareStatusMessage = msg == null ? "" : msg;
if (shareStatusLabel != null) shareStatusLabel.setMessage(Component.literal(shareStatusMessage));
}
/** Trim, strip matched wrapping quotes, reject null bytes and absurdly long inputs. */
private static String sanitizeSharePath(String raw) {
if (raw == null) return "";
String s = raw.trim();
if (s.length() >= 2
&& ((s.charAt(0) == '"' && s.charAt(s.length() - 1) == '"')
|| (s.charAt(0) == '\'' && s.charAt(s.length() - 1) == '\''))) {
s = s.substring(1, s.length() - 1).trim();
}
if (s.indexOf('\0') >= 0) return "";
if (s.length() > SHARE_PATH_MAX_LEN) return "";
return s;
}
/** Trim, drop control characters, cap length. Null byte / DEL / C0 all stripped. */
private static String sanitizeShareName(String raw) {
if (raw == null) return "";
String s = raw.trim();
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c >= 0x20 && c != 0x7F) sb.append(c);
}
String out = sb.toString();
if (out.length() > SHARE_NAME_MAX_LEN) out = out.substring(0, SHARE_NAME_MAX_LEN);
return out;
}
/** "red_hat.png" → "Red Hat". Used when the user leaves the name field blank. */
private static String titleCaseFilenameStem(String filename) {
int dot = filename.lastIndexOf('.');
String stem = dot < 0 ? filename : filename.substring(0, dot);
StringBuilder out = new StringBuilder(stem.length());
boolean capitalize = true;
for (int i = 0; i < stem.length(); i++) {
char c = stem.charAt(i);
if (c == '_' || c == '-' || c == ' ') {
out.append(' ');
capitalize = true;
} else if (capitalize) {
out.append(Character.toUpperCase(c));
capitalize = false;
} else {
out.append(c);
}
}
return out.toString();
}
private void equip(String category, Identifier cosmeticId) { private void equip(String category, Identifier cosmeticId) {
Minecraft mc = Minecraft.getInstance(); Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player; LocalPlayer player = mc.player;
if (player == null || player.connection == null) return; if (player == null || player.connection == null) return;
// Toggle: if this cosmetic is already equipped in that category, unequip. // Toggle: if this cosmetic is already equipped in that category, unequip.
Map<String, Identifier> currentEquipped = player.getData(CosmeticAttachments.EQUIPPED.get()); Map<String, com.razz.dfashion.cosmetic.CosmeticRef> currentEquipped =
String cmd = cosmeticId.equals(currentEquipped.get(category)) player.getData(CosmeticAttachments.EQUIPPED.get());
com.razz.dfashion.cosmetic.CosmeticRef current = currentEquipped.get(category);
boolean alreadyEquipped = current instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l
&& l.id().equals(cosmeticId);
String cmd = alreadyEquipped
? "decofashion unequip " + category ? "decofashion unequip " + category
: "decofashion equip " + category + " " + cosmeticId; : "decofashion equip " + category + " " + cosmeticId;
player.connection.sendUnattendedCommand(cmd, this); player.connection.sendUnattendedCommand(cmd, this);
@ -423,6 +968,16 @@ public class ClosetScreen extends Screen {
@Override @Override
public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) { public boolean mouseScrolled(double mx, double my, double deltaX, double deltaY) {
// Shared tab uses the full right-side region as a vertical scroll area.
if (SHARED_TAB.equals(selectedCategory)
&& my >= SHARED_GRID_TOP
&& my <= this.height - SHARED_GRID_BOTTOM_PAD
&& mx >= SKIN_GRID_START_X) {
sharedVerticalScroll -= (int) (deltaY * 20);
clampSharedScroll();
rebuildCosmeticRow();
return true;
}
if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10 if (my > this.height - COSMETIC_ROW_BOTTOM_MARGIN - 10
&& my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) { && my < this.height - COSMETIC_ROW_BOTTOM_MARGIN + COSMETIC_SIZE + 10) {
cosmeticScroll -= (int) (deltaY * 20); cosmeticScroll -= (int) (deltaY * 20);
@ -445,12 +1000,20 @@ public class ClosetScreen extends Screen {
// covers uploads (which add a new entry asynchronously after the server echo arrives) // covers uploads (which add a new entry asynchronously after the server echo arrives)
// and any new skin received from another player. // and any new skin received from another player.
if (SKIN_TAB.equals(selectedCategory)) { if (SKIN_TAB.equals(selectedCategory)) {
int count = ClientSkinCache.snapshotRegistered().size(); int count = player.getData(SkinAttachments.LIBRARY.get()).entries().size();
if (count != lastKnownSkinCount) { if (count != lastKnownSkinCount) {
lastKnownSkinCount = count; lastKnownSkinCount = count;
rebuildCosmeticRow(); rebuildCosmeticRow();
} }
} }
if (SHARED_TAB.equals(selectedCategory)) {
int count = player.getData(CosmeticAttachments.SHARED_LIBRARY.get()).entries().size();
if (count != lastKnownSharedCount) {
lastKnownSharedCount = count;
clampSharedScroll();
rebuildCosmeticRow();
}
}
long handle = mc.getWindow().handle(); long handle = mc.getWindow().handle();
boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS; boolean leftDown = GLFW.glfwGetMouseButton(handle, GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
@ -481,8 +1044,11 @@ public class ClosetScreen extends Screen {
String hit = findClickedEquippedCategory(player, mx, my); String hit = findClickedEquippedCategory(player, mx, my);
if (hit != null) { if (hit != null) {
selectCategory(hit); selectCategory(hit);
Identifier current = player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit); com.razz.dfashion.cosmetic.CosmeticRef ref =
if (current != null) scrollToCosmetic(hit, current); player.getData(CosmeticAttachments.EQUIPPED.get()).get(hit);
if (ref instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l) {
scrollToCosmetic(hit, l.id());
}
} }
} }
} }
@ -541,7 +1107,8 @@ public class ClosetScreen extends Screen {
* camera-local space (subtract {@code camera.position()}). * camera-local space (subtract {@code camera.position()}).
*/ */
private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) { private @Nullable String findClickedEquippedCategory(LocalPlayer player, double clickX, double clickY) {
Map<String, Identifier> equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); Map<String, com.razz.dfashion.cosmetic.CosmeticRef> equipped =
player.getData(CosmeticAttachments.EQUIPPED.get());
if (equipped.isEmpty()) return null; if (equipped.isEmpty()) return null;
Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera(); Camera camera = Minecraft.getInstance().gameRenderer.getMainCamera();
@ -554,7 +1121,7 @@ public class ClosetScreen extends Screen {
String best = null; String best = null;
double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX; double bestDistSq = PLAYER_CLICK_THRESHOLD_PX * PLAYER_CLICK_THRESHOLD_PX;
for (Map.Entry<String, Identifier> entry : equipped.entrySet()) { for (Map.Entry<String, com.razz.dfashion.cosmetic.CosmeticRef> entry : equipped.entrySet()) {
String category = entry.getKey(); String category = entry.getKey();
Vec3 bonePos = approximateBoneWorldPos(player, category); Vec3 bonePos = approximateBoneWorldPos(player, category);
@ -603,9 +1170,10 @@ public class ClosetScreen extends Screen {
int count; int count;
int rowLeft; int rowLeft;
if (SKIN_TAB.equals(selectedCategory)) { if (SKIN_TAB.equals(selectedCategory)) {
// Skin grid: rows come from the client skin cache, and they start past the // Skin grid: rows come from the server-authoritative player library (not the
// vertical action column, not at COSMETIC_ROW_LEFT. // client's content-addressed blob pool), so only this player's 5 slots count.
count = ClientSkinCache.snapshotRegistered().size(); LocalPlayer p = Minecraft.getInstance().player;
count = p != null ? p.getData(SkinAttachments.LIBRARY.get()).entries().size() : 0;
rowLeft = SKIN_GRID_START_X; rowLeft = SKIN_GRID_START_X;
} else { } else {
count = 0; count = 0;
@ -666,11 +1234,18 @@ public class ClosetScreen extends Screen {
renderSkinPreviews(graphics, player); renderSkinPreviews(graphics, player);
return; return;
} }
if (SHARED_TAB.equals(selectedCategory)) {
renderSharedPreviews(graphics, player);
return;
}
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher(); EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player); EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
Map<String, Identifier> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get()); Map<String, com.razz.dfashion.cosmetic.CosmeticRef> liveEquipped =
player.getData(CosmeticAttachments.EQUIPPED.get());
// Authored loop first. Shared entries appended to this category tab render via
// renderSharedPreviews below — they're not matched by the catalog iteration.
int idx = 0; int idx = 0;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) { for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue(); CosmeticDefinition def = entry.getValue();
@ -688,7 +1263,9 @@ public class ClosetScreen extends Screen {
// Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic). // Skip off-screen or encroaching-on-tabs buttons (match btn.visible logic).
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue; if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
boolean isEquipped = entry.getKey().equals(liveEquipped.get(def.category())); com.razz.dfashion.cosmetic.CosmeticRef liveRef = liveEquipped.get(def.category());
boolean isEquipped = liveRef instanceof com.razz.dfashion.cosmetic.CosmeticRef.Local l
&& entry.getKey().equals(l.id());
int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR; int borderColor = isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_COLOR;
// Frame (green if currently equipped). // Frame (green if currently equipped).
@ -715,7 +1292,7 @@ public class ClosetScreen extends Screen {
// Register the override — layer will read this instead of the live player's // Register the override — layer will read this instead of the live player's
// equipped attachment when rendering this specific render state. // equipped attachment when rendering this specific render state.
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar, CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
Map.of(def.category(), entry.getKey())); Map.of(def.category(), new com.razz.dfashion.cosmetic.CosmeticRef.Local(entry.getKey())));
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f); Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI); Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
@ -724,6 +1301,80 @@ public class ClosetScreen extends Screen {
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation, graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
x0, y0, x1, y1); x0, y0, x1, y1);
} }
// Shared entries that `rebuildCosmeticRow` appended to this category tab render
// via the shared preview path — they're not in the catalog.
renderSharedPreviews(graphics, player);
}
/**
* 3D-mini-player preview per shared-library entry. Per-box we inject a
* {@link CosmeticRef.Shared} override so the cosmetic renders in the category derived
* from the entry's bones. Boxes that are scrolled off the grid are skipped. Green frame
* when equipped; amber frame for un-equipped shared entries so they're distinguishable
* from authored ones (white frame).
*/
private void renderSharedPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
if (sharedButtonHashes.isEmpty()) return;
Minecraft mc = Minecraft.getInstance();
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
Map<String, CosmeticRef> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
// Vertical clip only matters on the shared tab (vertical scroll grid).
// On authored category tabs the shared buttons live in the bottom row alongside
// authored ones, so we use the full-screen range and rely on the button's visible
// flag + horizontal clip.
boolean onSharedTab = SHARED_TAB.equals(selectedCategory);
int gridTop = onSharedTab ? SHARED_GRID_TOP : 0;
int gridBottom = onSharedTab ? (this.height - SHARED_GRID_BOTTOM_PAD) : this.height;
for (Map.Entry<Button, String> e : sharedButtonHashes.entrySet()) {
Button btn = e.getKey();
String hash = e.getValue();
if (!btn.visible) continue;
int fx0 = btn.getX();
int fy0 = btn.getY();
int fx1 = fx0 + btn.getWidth();
int fy1 = fy0 + btn.getHeight();
// Clip against vertical-scroll viewport.
if (fy1 <= gridTop || fy0 >= gridBottom) continue;
if (fx0 < COSMETIC_ROW_LEFT || fx0 >= this.width) continue;
// Baked entry may not have arrived yet (requestIfMissing sent above).
if (ClientSharedCosmeticCache.getBaked(hash) == null) continue;
String previewCategory = sharedCategoryForHash(hash);
CosmeticRef liveRef = liveEquipped.get(previewCategory);
boolean isEquipped = liveRef instanceof CosmeticRef.Shared s && hash.equals(s.hash());
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_SHARED_COLOR);
int x0 = fx0 + COSMETIC_INNER_PADDING;
int y0 = fy0 + COSMETIC_INNER_PADDING;
int x1 = fx1 - COSMETIC_INNER_PADDING;
int y1 = fy1 - COSMETIC_INNER_PADDING;
EntityRenderState state = renderer.createRenderState(player, 1.0F);
if (!(state instanceof AvatarRenderState avatar)) continue;
avatar.shadowPieces.clear();
avatar.outlineColor = 0;
avatar.bodyRot = 180f + COSMETIC_PREVIEW_YAW;
avatar.yRot = COSMETIC_PREVIEW_YAW;
avatar.xRot = 0f;
CosmeticRenderLayer.RENDER_OVERRIDES.put(avatar,
Map.of(previewCategory, new CosmeticRef.Shared(hash)));
Vector3f translation = new Vector3f(0f, avatar.boundingBoxHeight / 2f + 0.0625f, 0f);
Quaternionf rotation = new Quaternionf().rotateZ((float) Math.PI);
Quaternionf xRotation = new Quaternionf();
graphics.entity(avatar, COSMETIC_PREVIEW_SIZE, translation, rotation, xRotation,
x0, y0, x1, y1);
}
} }
/** /**

@ -2,11 +2,12 @@ package com.razz.dfashion.cosmetic;
import com.mojang.serialization.Codec; import com.mojang.serialization.Codec;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.cosmetic.share.CosmeticLibrary;
import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.RegistryFriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.codec.StreamCodec;
import net.minecraft.resources.Identifier; import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.attachment.AttachmentType; import net.neoforged.neoforge.attachment.AttachmentType;
import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister; import net.neoforged.neoforge.registries.DeferredRegister;
@ -20,22 +21,40 @@ public final class CosmeticAttachments {
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES = public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID); DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
private static final Codec<Map<String, Identifier>> MAP_CODEC = private static final Codec<Map<String, CosmeticRef>> MAP_CODEC =
Codec.unboundedMap(Codec.STRING, Identifier.CODEC); Codec.unboundedMap(Codec.STRING, CosmeticRef.CODEC);
private static final StreamCodec<RegistryFriendlyByteBuf, Map<String, Identifier>> STREAM_CODEC = private static final StreamCodec<RegistryFriendlyByteBuf, Map<String, CosmeticRef>> STREAM_CODEC =
ByteBufCodecs.map(HashMap::new, ByteBufCodecs.STRING_UTF8, Identifier.STREAM_CODEC); ByteBufCodecs.map(HashMap::new, ByteBufCodecs.stringUtf8(64), CosmeticRef.STREAM_CODEC);
/** Per-player map of category -> equipped cosmetic id. */ /** Per-player map of category -> equipped cosmetic ref. A ref is either a
public static final DeferredHolder<AttachmentType<?>, AttachmentType<Map<String, Identifier>>> EQUIPPED = * {@link CosmeticRef.Local} (built-in / user-folder cosmetic) or a
* {@link CosmeticRef.Shared} (server-stored blob by content hash). */
public static final DeferredHolder<AttachmentType<?>, AttachmentType<Map<String, CosmeticRef>>> EQUIPPED =
ATTACHMENT_TYPES.register( ATTACHMENT_TYPES.register(
"equipped_cosmetics", "equipped_cosmetics",
() -> AttachmentType.<Map<String, Identifier>>builder(() -> new HashMap<>()) () -> AttachmentType.<Map<String, CosmeticRef>>builder(() -> new HashMap<>())
.serialize(MAP_CODEC.fieldOf("equipped")) .serialize(MAP_CODEC.fieldOf("equipped"))
.sync(STREAM_CODEC) .sync(STREAM_CODEC)
.copyOnDeath() .copyOnDeath()
.build() .build()
); );
/** Personal library of shared-cosmetic hashes. Synced <b>only to the owning player</b>
* so nobody can enumerate another player's uploaded cosmetics. */
public static final DeferredHolder<AttachmentType<?>, AttachmentType<CosmeticLibrary>> SHARED_LIBRARY =
ATTACHMENT_TYPES.register(
"shared_cosmetic_library",
() -> AttachmentType.<CosmeticLibrary>builder(() -> CosmeticLibrary.EMPTY)
.serialize(CosmeticLibrary.CODEC.fieldOf("shared_library"))
.sync(
(holder, to) -> holder instanceof ServerPlayer sp
&& sp.getUUID().equals(to.getUUID()),
CosmeticLibrary.STREAM_CODEC
)
.copyOnDeath()
.build()
);
private CosmeticAttachments() {} private CosmeticAttachments() {}
} }

@ -48,8 +48,8 @@ public final class CosmeticCommands {
String idStr = StringArgumentType.getString(ctx, "id"); String idStr = StringArgumentType.getString(ctx, "id");
Identifier id = Identifier.parse(idStr); Identifier id = Identifier.parse(idStr);
Map<String, Identifier> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); Map<String, CosmeticRef> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
equipped.put(category, id); equipped.put(category, new CosmeticRef.Local(id));
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped); player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
ctx.getSource().sendSuccess( ctx.getSource().sendSuccess(
@ -67,13 +67,13 @@ public final class CosmeticCommands {
} }
String category = StringArgumentType.getString(ctx, "category"); String category = StringArgumentType.getString(ctx, "category");
Map<String, Identifier> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get())); Map<String, CosmeticRef> equipped = new HashMap<>(player.getData(CosmeticAttachments.EQUIPPED.get()));
Identifier removed = equipped.remove(category); CosmeticRef removed = equipped.remove(category);
player.setData(CosmeticAttachments.EQUIPPED.get(), equipped); player.setData(CosmeticAttachments.EQUIPPED.get(), equipped);
ctx.getSource().sendSuccess( ctx.getSource().sendSuccess(
() -> Component.literal(removed != null () -> Component.literal(removed != null
? "Unequipped " + removed + " from '" + category + "'" ? "Unequipped " + formatRef(removed) + " from '" + category + "'"
: "Nothing was equipped in '" + category + "'"), : "Nothing was equipped in '" + category + "'"),
false false
); );
@ -86,16 +86,23 @@ public final class CosmeticCommands {
ctx.getSource().sendFailure(Component.literal("Must be run by a player")); ctx.getSource().sendFailure(Component.literal("Must be run by a player"));
return 0; return 0;
} }
Map<String, Identifier> equipped = player.getData(CosmeticAttachments.EQUIPPED.get()); Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
if (equipped.isEmpty()) { if (equipped.isEmpty()) {
ctx.getSource().sendSuccess(() -> Component.literal("No cosmetics equipped"), false); ctx.getSource().sendSuccess(() -> Component.literal("No cosmetics equipped"), false);
} else { } else {
equipped.forEach((cat, id) -> equipped.forEach((cat, ref) ->
ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + id), false) ctx.getSource().sendSuccess(() -> Component.literal(cat + " = " + formatRef(ref)), false)
); );
} }
return 1; return 1;
} }
private static String formatRef(CosmeticRef ref) {
return switch (ref) {
case CosmeticRef.Local l -> l.id().toString();
case CosmeticRef.Shared s -> "shared:" + s.hash().substring(0, 8) + "…";
};
}
private CosmeticCommands() {} private CosmeticCommands() {}
} }

@ -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,300 @@
package com.razz.dfashion.cosmetic.share;
import com.razz.dfashion.bbmodel.BbCube;
import com.razz.dfashion.bbmodel.BbFace;
import com.razz.dfashion.bbmodel.BbGroup;
import com.razz.dfashion.bbmodel.BbLocator;
import com.razz.dfashion.bbmodel.BbModelParser;
import com.razz.dfashion.bbmodel.BbOutlinerNode;
import com.razz.dfashion.bbmodel.Bbmodel;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.DecoderException;
import net.minecraft.network.VarInt;
import net.minecraft.network.codec.StreamCodec;
import org.joml.Vector3f;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Binary wire format for a {@link Bbmodel}. The author's client parses their local JSON
* with {@link BbModelParser} (the only JSON parser anywhere in the mod's untrusted-bytes
* path), encodes the vetted record via this codec, and everything downstream server,
* receivers only ever decodes via this codec. GSON is never fed wire bytes.
*
* <p>Defense layers:
* <ul>
* <li>Every integer length is VAR_INT and bounded: strings {@value #MAX_STRING_LEN} chars,
* lists {@value #MAX_LIST_ELEMENTS} elements, face UVs {@value #MAX_UV_LEN} floats.
* A malicious {@code VAR_INT = 2^30} is rejected before any allocation.</li>
* <li>Outliner recursion is bounded by {@link BbModelParser#MAX_OUTLINER_DEPTH} because
* {@link BbModelParser#validate} re-runs on decoded output.</li>
* <li>After decoding, the full semantic validator fires again. Two separate code paths
* (codec + validator) must both miss a bug for anything malformed to reach the baker.</li>
* </ul>
*/
public final class BbmodelCodec {
public static final int MAX_STRING_LEN = 256;
/** Per-list element cap. Bounds up-front allocation before ByteBuf consumption catches up. */
public static final int MAX_LIST_ELEMENTS = 65536;
/** Per-face UV length cap. Real bbmodel faces use 4 floats; 32 is generous slack. */
public static final int MAX_UV_LEN = 32;
public static final StreamCodec<ByteBuf, Bbmodel> CODEC = new StreamCodec<>() {
@Override public void encode(ByteBuf buf, Bbmodel m) { writeBbmodel(buf, m); }
@Override public Bbmodel decode(ByteBuf buf) {
Bbmodel m = readBbmodel(buf);
try {
BbModelParser.validate(m);
} catch (RuntimeException ex) {
throw new DecoderException("bbmodel failed wire-side validation: " + ex.getMessage(), ex);
}
return m;
}
};
private BbmodelCodec() {}
// ---- Bbmodel ----
private static void writeBbmodel(ByteBuf buf, Bbmodel m) {
VarInt.write(buf, m.resolutionWidth());
VarInt.write(buf, m.resolutionHeight());
writeList(buf, m.elements(), BbmodelCodec::writeCube);
writeList(buf, m.locators(), BbmodelCodec::writeLocator);
writeList(buf, m.groups(), BbmodelCodec::writeGroup);
writeList(buf, m.outliner(), BbmodelCodec::writeOutlinerNode);
}
private static Bbmodel readBbmodel(ByteBuf buf) {
int w = VarInt.read(buf);
int h = VarInt.read(buf);
List<BbCube> elements = readList(buf, BbmodelCodec::readCube);
List<BbLocator> locators = readList(buf, BbmodelCodec::readLocator);
List<BbGroup> groups = readList(buf, BbmodelCodec::readGroup);
List<BbOutlinerNode> outliner = readList(buf, BbmodelCodec::readOutlinerNode);
return new Bbmodel(w, h, elements, locators, groups, outliner);
}
// ---- BbCube ----
private static void writeCube(ByteBuf buf, BbCube c) {
writeString(buf, c.uuid());
writeString(buf, c.name());
writeVec3f(buf, c.from());
writeVec3f(buf, c.to());
writeVec3f(buf, c.origin());
writeVec3f(buf, c.rotation());
buf.writeFloat(c.inflate());
Map<String, BbFace> faces = c.faces() != null ? c.faces() : Map.of();
if (faces.size() > MAX_LIST_ELEMENTS) {
throw new DecoderException("face map too large: " + faces.size());
}
// Canonical order so authors on different JVMs / Map implementations produce identical
// binary and therefore identical content hashes. Breaks dedup if this ever changes.
List<Map.Entry<String, BbFace>> sortedFaces = new ArrayList<>(faces.entrySet());
sortedFaces.sort(Map.Entry.comparingByKey());
VarInt.write(buf, sortedFaces.size());
for (Map.Entry<String, BbFace> e : sortedFaces) {
writeString(buf, e.getKey());
writeFace(buf, e.getValue());
}
}
private static BbCube readCube(ByteBuf buf) {
String uuid = readString(buf);
String name = readString(buf);
Vector3f from = readVec3f(buf);
Vector3f to = readVec3f(buf);
Vector3f origin = readVec3f(buf);
Vector3f rotation = readVec3f(buf);
float inflate = buf.readFloat();
int fcount = VarInt.read(buf);
if (fcount < 0 || fcount > MAX_LIST_ELEMENTS) {
throw new DecoderException("face count out of range: " + fcount);
}
Map<String, BbFace> faces = new HashMap<>(Math.min(fcount, 16));
for (int i = 0; i < fcount; i++) {
String key = readString(buf);
faces.put(key, readFace(buf));
}
return new BbCube(uuid, name, from, to, inflate, origin, rotation, faces);
}
// ---- BbFace ----
private static void writeFace(ByteBuf buf, BbFace f) {
float[] uv = f.uv() != null ? f.uv() : new float[0];
if (uv.length > MAX_UV_LEN) throw new DecoderException("uv length too large: " + uv.length);
VarInt.write(buf, uv.length);
for (float v : uv) buf.writeFloat(v);
Integer texture = f.texture();
if (texture == null) {
buf.writeByte(0);
} else {
buf.writeByte(1);
VarInt.write(buf, texture);
}
VarInt.write(buf, f.rotation());
}
private static BbFace readFace(ByteBuf buf) {
int n = VarInt.read(buf);
if (n < 0 || n > MAX_UV_LEN) throw new DecoderException("uv length out of range: " + n);
float[] uv = new float[n];
for (int i = 0; i < n; i++) uv[i] = buf.readFloat();
byte flag = buf.readByte();
Integer texture;
if (flag == 0) texture = null;
else if (flag == 1) texture = VarInt.read(buf);
else throw new DecoderException("bad texture flag " + flag);
int rotation = VarInt.read(buf);
return new BbFace(uv, texture, rotation);
}
// ---- BbLocator / BbGroup ----
private static void writeLocator(ByteBuf buf, BbLocator loc) {
writeString(buf, loc.uuid());
writeString(buf, loc.name());
writeVec3f(buf, loc.position());
writeVec3f(buf, loc.rotation());
}
private static BbLocator readLocator(ByteBuf buf) {
return new BbLocator(readString(buf), readString(buf), readVec3f(buf), readVec3f(buf));
}
private static void writeGroup(ByteBuf buf, BbGroup g) {
writeString(buf, g.uuid());
writeString(buf, g.name());
writeVec3f(buf, g.origin());
writeVec3f(buf, g.rotation());
}
private static BbGroup readGroup(ByteBuf buf) {
return new BbGroup(readString(buf), readString(buf), readVec3f(buf), readVec3f(buf));
}
// ---- BbOutlinerNode (recursive) ----
private static final byte TAG_ELEMENT = 0;
private static final byte TAG_GROUP = 1;
private static void writeOutlinerNode(ByteBuf buf, BbOutlinerNode node) {
switch (node) {
case BbOutlinerNode.ElementRef er -> {
buf.writeByte(TAG_ELEMENT);
writeString(buf, er.uuid());
}
case BbOutlinerNode.GroupRef gr -> {
buf.writeByte(TAG_GROUP);
writeString(buf, gr.uuid());
writeList(buf, gr.children(), BbmodelCodec::writeOutlinerNode);
}
}
}
private static BbOutlinerNode readOutlinerNode(ByteBuf buf) {
return readOutlinerNode(buf, 0);
}
/**
* Depth-tracked recursive decode. Caps at {@link BbModelParser#MAX_OUTLINER_DEPTH}
* <b>during decode</b> because the semantic validator in {@link BbModelParser#validate}
* only runs after full construction a malicious payload with unbounded nesting would
* otherwise blow the thread's stack before validation ever got a chance to fire.
* One GroupRef nesting = ~2 wire bytes, so 16 MB of payload is more than enough to
* overflow a typical ~512 KB JVM stack without this cap.
*/
private static BbOutlinerNode readOutlinerNode(ByteBuf buf, int depth) {
if (depth > BbModelParser.MAX_OUTLINER_DEPTH) {
throw new DecoderException(
"outliner nesting exceeds decode-time depth cap (" + BbModelParser.MAX_OUTLINER_DEPTH + ")");
}
byte tag = buf.readByte();
return switch (tag) {
case TAG_ELEMENT -> new BbOutlinerNode.ElementRef(readString(buf));
case TAG_GROUP -> {
String uuid = readString(buf);
int n = VarInt.read(buf);
if (n < 0 || n > MAX_LIST_ELEMENTS) {
throw new DecoderException("outliner children count out of range: " + n);
}
List<BbOutlinerNode> children = new ArrayList<>(Math.min(n, 1024));
for (int i = 0; i < n; i++) {
children.add(readOutlinerNode(buf, depth + 1));
}
yield new BbOutlinerNode.GroupRef(uuid, children);
}
default -> throw new DecoderException("bad outliner node tag " + tag);
};
}
// ---- primitives ----
private static void writeVec3f(ByteBuf buf, Vector3f v) {
Vector3f safe = v != null ? v : new Vector3f();
buf.writeFloat(safe.x);
buf.writeFloat(safe.y);
buf.writeFloat(safe.z);
}
private static Vector3f readVec3f(ByteBuf buf) {
return new Vector3f(buf.readFloat(), buf.readFloat(), buf.readFloat());
}
private static void writeString(ByteBuf buf, String s) {
if (s == null) s = "";
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_STRING_LEN * 4) {
throw new DecoderException("string too long on encode: " + bytes.length);
}
VarInt.write(buf, bytes.length);
buf.writeBytes(bytes);
}
private static String readString(ByteBuf buf) {
int n = VarInt.read(buf);
// UTF-8 string of N chars can take up to 4N bytes; cap the byte length at 4× the char cap.
if (n < 0 || n > MAX_STRING_LEN * 4) {
throw new DecoderException("string byte length out of range: " + n);
}
byte[] bytes = new byte[n];
buf.readBytes(bytes);
String s = new String(bytes, StandardCharsets.UTF_8);
if (s.length() > MAX_STRING_LEN) {
throw new DecoderException("string too long after decode: " + s.length());
}
return s;
}
// ---- list helpers ----
@FunctionalInterface private interface Writer<T> { void write(ByteBuf buf, T t); }
@FunctionalInterface private interface Reader<T> { T read(ByteBuf buf); }
private static <T> void writeList(ByteBuf buf, List<T> list, Writer<T> w) {
List<T> safe = list != null ? list : List.of();
if (safe.size() > MAX_LIST_ELEMENTS) {
throw new DecoderException("list too large: " + safe.size());
}
VarInt.write(buf, safe.size());
for (T t : safe) w.write(buf, t);
}
private static <T> List<T> readList(ByteBuf buf, Reader<T> r) {
int n = VarInt.read(buf);
if (n < 0 || n > MAX_LIST_ELEMENTS) {
throw new DecoderException("list length out of range: " + n);
}
List<T> out = new ArrayList<>(Math.min(n, 1024));
for (int i = 0; i < n; i++) out.add(r.read(buf));
return out;
}
}

@ -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,517 @@
package com.razz.dfashion.skin;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.zip.CRC32;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
/**
* Memory-safe, pure-Java PNG decoder restricted to 8-bit color-type-6 (RGBA) images.
*
* <p>Verifies the PNG magic, parses IHDR, enforces a hard dimension cap, whitelists only
* {@code IHDR/IDAT/IEND} chunks, checks every CRC-32, rejects any trailing bytes past IEND,
* inflates the IDAT stream with a cumulative output cap equal to the declared pixel budget,
* and applies the standard PNG unfilter (None/Sub/Up/Average/Paeth).
*
* <p>This is the only PNG parser on the sync path called on the uploading client to turn
* a user-supplied {@code .png} into raw RGBA pixels that then travel over the wire. STB
* (via {@code NativeImage.read}) is never invoked with untrusted bytes. The rejected-chunk
* whitelist, CRC check, and trailing-byte rule together eliminate pdvzip-style polyglot
* PNG/ZIP/JAR payloads: any byte that isn't part of a valid {@code IHDR/IDAT/IEND} chunk
* triggers a {@link BadPngException}.
*
* <p>Zero dependencies beyond {@code java.util.zip}.
*/
public final class SafePngReader {
public static final int MAX_DIM = 4096;
/** Cap on a single chunk's declared length (128 MB). Defensive; real chunks are tiny. */
private static final int MAX_CHUNK_BYTES = 1 << 27;
private static final byte[] PNG_SIGNATURE =
{ (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
private static final int TYPE_IHDR = 0x49484452;
private static final int TYPE_IDAT = 0x49444154;
private static final int TYPE_IEND = 0x49454E44;
private static final int TYPE_PLTE = 0x504C5445;
private static final int TYPE_tRNS = 0x74524E53;
private static final int COLOR_GRAYSCALE = 0;
private static final int COLOR_RGB = 2;
private static final int COLOR_PALETTE = 3;
private static final int COLOR_GRAYSCALE_ALPHA = 4;
private static final int COLOR_RGBA = 6;
public static final class Image {
public final int width;
public final int height;
/** Tightly packed RGBA, length == width * height * 4. */
public final byte[] rgba;
public Image(int width, int height, byte[] rgba) {
this.width = width;
this.height = height;
this.rgba = rgba;
}
}
public static final class BadPngException extends IOException {
public BadPngException(String message) { super(message); }
}
private SafePngReader() {}
public static Image decode(byte[] src) throws IOException {
if (src == null || src.length < PNG_SIGNATURE.length) {
throw new BadPngException("too short");
}
for (int i = 0; i < PNG_SIGNATURE.length; i++) {
if (src[i] != PNG_SIGNATURE[i]) throw new BadPngException("bad PNG signature");
}
int width = -1, height = -1, bitDepth = 0, colorType = -1;
byte[] palette = null; // PLTE: length is multiple of 3, up to 768 bytes (256 × RGB)
byte[] trnsAlpha = null; // tRNS: up to paletteEntries bytes (1 alpha per index)
boolean sawIHDR = false, sawIEND = false, sawIDAT = false;
ByteArrayOutputStream idat = new ByteArrayOutputStream();
CRC32 crc = new CRC32();
int p = PNG_SIGNATURE.length;
while (p < src.length) {
if (p + 8 > src.length) throw new BadPngException("truncated chunk header");
int len = readU32(src, p);
int type = readU32(src, p + 4);
if (len < 0 || len > MAX_CHUNK_BYTES) {
throw new BadPngException("chunk length out of range: " + (len & 0xFFFFFFFFL));
}
int dataStart = p + 8;
int dataEnd = dataStart + len;
int crcEnd = dataEnd + 4;
if (crcEnd > src.length) throw new BadPngException("truncated chunk body");
crc.reset();
crc.update(src, p + 4, 4 + len);
int actual = (int) crc.getValue();
int declared = readU32(src, dataEnd);
if (actual != declared) {
throw new BadPngException("bad CRC on chunk " + chunkName(type));
}
if (sawIEND) throw new BadPngException("trailing chunk after IEND");
switch (type) {
case TYPE_IHDR: {
if (sawIHDR) throw new BadPngException("duplicate IHDR");
if (len != 13) throw new BadPngException("bad IHDR length");
width = readU32(src, dataStart);
height = readU32(src, dataStart + 4);
bitDepth = src[dataStart + 8] & 0xFF;
colorType = src[dataStart + 9] & 0xFF;
int compression = src[dataStart + 10] & 0xFF;
int filter = src[dataStart + 11] & 0xFF;
int interlace = src[dataStart + 12] & 0xFF;
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
throw new BadPngException("bad dimensions " + width + "x" + height);
}
// Accept the three color types common in authored skins/cosmetics:
// - Type 2 (RGB, no alpha) depths 8 / 16 — alpha synthesized as 255
// - Type 3 (palette/indexed) depths 1/2/4/8 — PLTE + optional tRNS
// - Type 6 (RGBA) depths 8 / 16 — native
// Types 0 (grayscale) and 4 (grayscale+alpha) are rejected — rare for
// textures, and supporting them would add parsing for edge cases no
// skin editor actually produces. 16-bit channels are downconverted per
// row during unfilter by keeping the high byte of each 16-bit sample.
if (!isSupportedDepthForColor(colorType, bitDepth)) {
throw new BadPngException(
"unsupported PNG format: color type " + colorType
+ " at " + bitDepth + "-bit depth");
}
if (compression != 0) throw new BadPngException("bad compression method");
if (filter != 0) throw new BadPngException("bad filter method");
if (interlace != 0) throw new BadPngException("interlaced PNGs not supported");
sawIHDR = true;
break;
}
case TYPE_PLTE: {
if (!sawIHDR) throw new BadPngException("PLTE before IHDR");
if (sawIDAT) throw new BadPngException("PLTE after IDAT");
if (palette != null) throw new BadPngException("duplicate PLTE");
if (len == 0 || len > 768 || (len % 3) != 0) {
throw new BadPngException("bad PLTE length: " + len);
}
palette = new byte[len];
System.arraycopy(src, dataStart, palette, 0, len);
break;
}
case TYPE_tRNS: {
if (!sawIHDR) throw new BadPngException("tRNS before IHDR");
if (sawIDAT) throw new BadPngException("tRNS after IDAT");
if (colorType != COLOR_PALETTE) {
// tRNS is also defined for types 0 and 2 (single transparent value),
// but we don't support those color types, so the chunk is nonsense.
throw new BadPngException("tRNS only supported on palette images");
}
if (palette == null) throw new BadPngException("tRNS before PLTE");
int paletteEntries = palette.length / 3;
if (len > paletteEntries) {
throw new BadPngException("tRNS longer than palette");
}
trnsAlpha = new byte[len];
System.arraycopy(src, dataStart, trnsAlpha, 0, len);
break;
}
case TYPE_IDAT: {
if (!sawIHDR) throw new BadPngException("IDAT before IHDR");
if (colorType == COLOR_PALETTE && palette == null) {
throw new BadPngException("IDAT before PLTE on palette image");
}
sawIDAT = true;
idat.write(src, dataStart, len);
break;
}
case TYPE_IEND: {
if (!sawIHDR) throw new BadPngException("IEND before IHDR");
if (len != 0) throw new BadPngException("IEND must be empty");
sawIEND = true;
break;
}
default:
// Ancillary chunks (per PNG spec: first letter lowercase, bit 5 of high byte set)
// are safe to skip. Their bytes never leave the uploading client — only pixels do —
// so polyglot/iCCP/trailing-IDAT payloads can't cross the wire regardless. Reject
// only unknown *critical* chunks.
if ((type & 0x20000000) == 0) {
throw new BadPngException("disallowed critical chunk " + chunkName(type));
}
break;
}
p = crcEnd;
}
if (!sawIHDR) throw new BadPngException("missing IHDR");
if (!sawIEND) throw new BadPngException("missing IEND");
if (p != src.length) throw new BadPngException("trailing bytes after IEND");
if (colorType == COLOR_PALETTE && palette == null) {
throw new BadPngException("palette image missing PLTE");
}
// PNG filtering is byte-level and uses ceil(bits-per-pixel / 8), min 1, as the
// stride for Sub/Paeth/Average. Scanline byte count is ceil(width * bits-per-pixel / 8).
int filterBpp = filterBpp(colorType, bitDepth);
long rowBytes = scanlineBytes(width, colorType, bitDepth);
// width/height are both validated to MAX_DIM above; 64-bit math keeps a future
// MAX_DIM bump from silently overflowing int into a negative allocation.
long rawLenL = (long) height * (1L + rowBytes);
if (rawLenL <= 0 || rawLenL > MAX_RAW_SCANLINE_BYTES) {
throw new BadPngException("raw scanline budget out of range: " + rawLenL);
}
int rawLen = (int) rawLenL;
byte[] raw = inflateBounded(idat.toByteArray(), rawLen);
byte[] rgba = unfilter(raw, width, height,
filterBpp, (int) rowBytes,
colorType, bitDepth, palette, trnsAlpha);
return new Image(width, height, rgba);
}
private static boolean isSupportedDepthForColor(int colorType, int bitDepth) {
return switch (colorType) {
case COLOR_GRAYSCALE -> bitDepth == 1 || bitDepth == 2 || bitDepth == 4
|| bitDepth == 8 || bitDepth == 16;
case COLOR_RGB, COLOR_RGBA, COLOR_GRAYSCALE_ALPHA -> bitDepth == 8 || bitDepth == 16;
case COLOR_PALETTE -> bitDepth == 1 || bitDepth == 2 || bitDepth == 4 || bitDepth == 8;
default -> false;
};
}
private static int filterBpp(int colorType, int bitDepth) {
// ceil(bits-per-pixel / 8), min 1. Palette and sub-byte grayscale both use 1.
return switch (colorType) {
case COLOR_GRAYSCALE -> Math.max(1, bitDepth / 8); // 1 for 1/2/4/8, 2 for 16
case COLOR_GRAYSCALE_ALPHA -> 2 * (bitDepth / 8); // 2 or 4
case COLOR_RGB -> 3 * (bitDepth / 8); // 3 or 6
case COLOR_RGBA -> 4 * (bitDepth / 8); // 4 or 8
case COLOR_PALETTE -> 1;
default -> throw new IllegalStateException("unreachable");
};
}
private static long scanlineBytes(int width, int colorType, int bitDepth) {
return switch (colorType) {
case COLOR_GRAYSCALE -> ((long) width * bitDepth + 7L) / 8L;
case COLOR_GRAYSCALE_ALPHA -> (long) width * 2L * (bitDepth / 8);
case COLOR_RGB -> (long) width * 3L * (bitDepth / 8);
case COLOR_RGBA -> (long) width * 4L * (bitDepth / 8);
case COLOR_PALETTE -> ((long) width * bitDepth + 7L) / 8L;
default -> throw new IllegalStateException("unreachable");
};
}
/** {@code MAX_DIM * (1 + MAX_DIM * 8)} the largest legal {@code rawLen} across both
* supported bit depths (8 = 4 bpp, 16 = 8 bpp). */
private static final long MAX_RAW_SCANLINE_BYTES = (long) MAX_DIM * (1L + (long) MAX_DIM * 8L);
private static byte[] inflateBounded(byte[] compressed, int expected) throws IOException {
Inflater inf = new Inflater();
try {
inf.setInput(compressed);
byte[] out = new byte[expected];
int written = 0;
byte[] scratch = new byte[64 * 1024];
while (true) {
int n;
try {
n = inf.inflate(scratch);
} catch (DataFormatException ex) {
throw new BadPngException("zlib decode error: " + ex.getMessage());
}
if (n == 0) {
if (inf.finished()) break;
if (inf.needsInput() || inf.needsDictionary()) {
throw new BadPngException("zlib stream truncated");
}
break;
}
if (written + n > expected) {
throw new BadPngException("inflated size exceeds pixel budget");
}
System.arraycopy(scratch, 0, out, written, n);
written += n;
}
if (written != expected) {
throw new BadPngException("inflated size mismatch: " + written + " != " + expected);
}
return out;
} finally {
inf.end();
}
}
/**
* Unfilter each scanline and emit 8-bit RGBA. The PNG filter algorithm is purely
* byte-level and uses {@code filterBpp} (ceil of bits-per-pixel / 8) as the stride
* for Sub/Average/Paeth it's indifferent to the color interpretation. The per-row
* conversion from raw bytes to 8-bit RGBA is where color type, bit depth, palette,
* and transparency come in. We do that conversion inline against a reusable row
* buffer so the peak allocation is the final RGBA output plus two scanline buffers.
*/
private static byte[] unfilter(byte[] raw, int width, int height,
int filterBpp, int rowBytes,
int colorType, int bitDepth,
byte[] palette, byte[] trnsAlpha) throws IOException {
long outLenL = (long) width * (long) height * 4L;
if (outLenL <= 0 || outLenL > (long) MAX_DIM * (long) MAX_DIM * 4L) {
throw new BadPngException("unfilter output size out of range: " + outLenL);
}
byte[] out = new byte[(int) outLenL];
byte[] prev = new byte[rowBytes];
byte[] curr = new byte[rowBytes];
int rp = 0;
int outPos = 0;
for (int y = 0; y < height; y++) {
int filter = raw[rp++] & 0xFF;
System.arraycopy(raw, rp, curr, 0, rowBytes);
rp += rowBytes;
switch (filter) {
case 0:
break;
case 1:
for (int x = filterBpp; x < rowBytes; x++) {
curr[x] = (byte) ((curr[x] & 0xFF) + (curr[x - filterBpp] & 0xFF));
}
break;
case 2:
for (int x = 0; x < rowBytes; x++) {
curr[x] = (byte) ((curr[x] & 0xFF) + (prev[x] & 0xFF));
}
break;
case 3:
for (int x = 0; x < rowBytes; x++) {
int left = (x >= filterBpp) ? (curr[x - filterBpp] & 0xFF) : 0;
int up = prev[x] & 0xFF;
curr[x] = (byte) ((curr[x] & 0xFF) + ((left + up) >>> 1));
}
break;
case 4:
for (int x = 0; x < rowBytes; x++) {
int left = (x >= filterBpp) ? (curr[x - filterBpp] & 0xFF) : 0;
int up = prev[x] & 0xFF;
int upLeft = (x >= filterBpp) ? (prev[x - filterBpp] & 0xFF) : 0;
curr[x] = (byte) ((curr[x] & 0xFF) + paeth(left, up, upLeft));
}
break;
default:
throw new BadPngException("unknown filter " + filter + " at row " + y);
}
outPos = emitRowAsRgba(curr, width, colorType, bitDepth, palette, trnsAlpha, out, outPos);
byte[] swap = prev; prev = curr; curr = swap;
}
return out;
}
/**
* Convert one unfiltered scanline {@code curr} into {@code width} RGBA pixels and
* write them into {@code out} starting at {@code outPos}. Returns the new {@code outPos}.
*/
private static int emitRowAsRgba(byte[] curr, int width,
int colorType, int bitDepth,
byte[] palette, byte[] trnsAlpha,
byte[] out, int outPos) throws IOException {
switch (colorType) {
case COLOR_RGBA -> {
if (bitDepth == 8) {
System.arraycopy(curr, 0, out, outPos, width * 4);
outPos += width * 4;
} else {
// 16-bit: high byte of each 2-byte big-endian sample.
for (int x = 0, src = 0; x < width; x++) {
out[outPos++] = curr[src]; src += 2;
out[outPos++] = curr[src]; src += 2;
out[outPos++] = curr[src]; src += 2;
out[outPos++] = curr[src]; src += 2;
}
}
}
case COLOR_RGB -> {
if (bitDepth == 8) {
for (int x = 0, src = 0; x < width; x++) {
out[outPos++] = curr[src++];
out[outPos++] = curr[src++];
out[outPos++] = curr[src++];
out[outPos++] = (byte) 0xFF;
}
} else {
for (int x = 0, src = 0; x < width; x++) {
out[outPos++] = curr[src]; src += 2;
out[outPos++] = curr[src]; src += 2;
out[outPos++] = curr[src]; src += 2;
out[outPos++] = (byte) 0xFF;
}
}
}
case COLOR_PALETTE -> {
int paletteEntries = palette.length / 3;
int trnsLen = trnsAlpha != null ? trnsAlpha.length : 0;
for (int x = 0; x < width; x++) {
int index = readPackedSample(curr, x, bitDepth);
if (index >= paletteEntries) {
throw new BadPngException(
"palette index " + index + " out of range (" + paletteEntries + ")");
}
int p = index * 3;
out[outPos++] = palette[p];
out[outPos++] = palette[p + 1];
out[outPos++] = palette[p + 2];
out[outPos++] = (index < trnsLen) ? trnsAlpha[index] : (byte) 0xFF;
}
}
case COLOR_GRAYSCALE -> {
if (bitDepth == 16) {
for (int x = 0, src = 0; x < width; x++) {
byte g = curr[src]; src += 2; // high byte of 16-bit sample
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = (byte) 0xFF;
}
} else if (bitDepth == 8) {
for (int x = 0; x < width; x++) {
byte g = curr[x];
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = (byte) 0xFF;
}
} else {
// 1/2/4-bit: scale sub-byte value to full 8-bit range via bit replication.
// 1-bit → ×0xFF, 2-bit → ×0x55, 4-bit → ×0x11. Equivalent to
// ((1 << 8) - 1) / ((1 << bitDepth) - 1) — fills with repeated pattern.
int scale = 0xFF / ((1 << bitDepth) - 1);
for (int x = 0; x < width; x++) {
byte g = (byte) (readPackedSample(curr, x, bitDepth) * scale);
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = (byte) 0xFF;
}
}
}
case COLOR_GRAYSCALE_ALPHA -> {
if (bitDepth == 8) {
for (int x = 0, src = 0; x < width; x++) {
byte g = curr[src++];
byte a = curr[src++];
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = a;
}
} else {
// 16-bit: high byte of each 2-byte sample.
for (int x = 0, src = 0; x < width; x++) {
byte g = curr[src]; src += 2;
byte a = curr[src]; src += 2;
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = g;
out[outPos++] = a;
}
}
}
default -> throw new IllegalStateException("unreachable");
}
return outPos;
}
/**
* Extract an MSB-first packed sample at pixel {@code x} for bit depths 1/2/4/8.
* Used for both palette indices and sub-byte grayscale.
*/
private static int readPackedSample(byte[] row, int x, int bitDepth) {
if (bitDepth == 8) {
return row[x] & 0xFF;
}
int pixelsPerByte = 8 / bitDepth;
int byteIndex = x / pixelsPerByte;
int withinByte = x % pixelsPerByte;
int shift = (pixelsPerByte - 1 - withinByte) * bitDepth;
int mask = (1 << bitDepth) - 1;
return (row[byteIndex] >> shift) & mask;
}
private static int paeth(int a, int b, int c) {
int p = a + b - c;
int pa = Math.abs(p - a);
int pb = Math.abs(p - b);
int pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
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 String chunkName(int type) {
return new String(new char[] {
(char) ((type >> 24) & 0xFF),
(char) ((type >> 16) & 0xFF),
(char) ((type >> 8) & 0xFF),
(char) (type & 0xFF)
});
}
}

@ -2,6 +2,7 @@ package com.razz.dfashion.skin;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.neoforge.attachment.AttachmentType; import net.neoforged.neoforge.attachment.AttachmentType;
import net.neoforged.neoforge.registries.DeferredHolder; import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister; import net.neoforged.neoforge.registries.DeferredRegister;
@ -12,6 +13,7 @@ public final class SkinAttachments {
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES = public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID); DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
/** Currently-equipped skin — visible to all trackers so everyone can render it. */
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinData>> DATA = public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinData>> DATA =
ATTACHMENT_TYPES.register( ATTACHMENT_TYPES.register(
"skin_data", "skin_data",
@ -22,5 +24,21 @@ public final class SkinAttachments {
.build() .build()
); );
/** Personal library of up to 5 uploaded skins. Synced <b>only to the owning player</b>
* so other clients can't see what's in someone else's wardrobe. */
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinLibrary>> LIBRARY =
ATTACHMENT_TYPES.register(
"skin_library",
() -> AttachmentType.<SkinLibrary>builder(() -> SkinLibrary.EMPTY)
.serialize(SkinLibrary.CODEC.fieldOf("library"))
.sync(
(holder, to) -> holder instanceof ServerPlayer sp
&& sp.getUUID().equals(to.getUUID()),
SkinLibrary.STREAM_CODEC
)
.copyOnDeath()
.build()
);
private SkinAttachments() {} private SkinAttachments() {}
} }

@ -3,6 +3,7 @@ package com.razz.dfashion.skin;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import net.minecraft.server.MinecraftServer; import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
@ -14,15 +15,44 @@ import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
/** /**
* Server-side skin store. Keeps PNG bytes on disk under {@code <serverDir>/decofashion/skins/<hash>.png}, * Server-side skin store. Holds <b>pixel blobs</b> on disk under
* and tracks in-flight multi-chunk uploads per player. * {@code <serverDir>/decofashion/skins/<hash>.dfskin} with an 8-byte header
* {@code [4-byte magic "DFSK"][u16 width][u16 height]} followed by the deflated RGBA payload.
*
* <p>The server never parses PNG. Uploads arrive as already-decoded, deflated RGBA from the
* uploading client (which ran a memory-safe pure-Java parser); the server only reassembles
* chunks, stream-inflates with a cumulative cap to compute the content hash, and writes the
* blob. Dedicated servers are therefore immune to PNG CVEs by construction, and the stored
* bytes aren't a valid PNG/ZIP/JAR under any rename there is no container. The
* {@code .dfskin} extension has no OS handler, and the magic header lets every read path
* reject anything that isn't our exact format.
*/ */
public final class SkinCache { public final class SkinCache {
public static final int MAX_BYTES = 32 * 1024 * 1024; // 32 MB /** Hard ceiling on raw RGBA size (4096² × 4 bytes = 64 MB). Per-image cap derives from dims. */
public static final int MAX_CHUNK_BYTES = 512 * 1024; // 512 KB public static final int MAX_RAW_BYTES = 4096 * 4096 * 4;
/** Per-wire-packet cap: keeps single packets well under the channel MTU. */
public static final int MAX_CHUNK_BYTES = 512 * 1024;
/** Total deflated bytes we'll accept for a single upload/download. */
public static final int MAX_DEFLATED_BYTES = 32 * 1024 * 1024;
/** Matches {@link SafePngReader#MAX_DIM}. */
public static final int MAX_DIM = SafePngReader.MAX_DIM;
/** Proprietary file-format extension. No OS or tool has a handler for it. */
public static final String FILE_EXT = ".dfskin";
/** "DFSK" in ASCII — prepended to every blob; every read validates it before trusting bytes. */
public static final byte[] MAGIC = { 'D', 'F', 'S', 'K' };
/** Magic (4) + width u16 (2) + height u16 (2) = 8. */
public static final int HEADER_SIZE = 8;
private static final String SUBDIR = "decofashion/skins"; private static final String SUBDIR = "decofashion/skins";
@ -34,6 +64,8 @@ public final class SkinCache {
private static final class Assembly { private static final class Assembly {
int expectedTotal; int expectedTotal;
int nextIndex; int nextIndex;
int width;
int height;
SkinModel model; SkinModel model;
ByteArrayOutputStream buffer; ByteArrayOutputStream buffer;
} }
@ -42,26 +74,58 @@ public final class SkinCache {
return server.getServerDirectory().resolve(SUBDIR); return server.getServerDirectory().resolve(SUBDIR);
} }
/**
* Whitelist check on hashes coming off the wire. Rejects anything that isn't exactly
* 64 lowercase hex chars which means {@code ".."}, slashes, null-bytes, and every
* other path-traversal trick can't even reach the filesystem.
*/
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) { public static Path fileFor(MinecraftServer server, String hash) {
return root(server).resolve(hash + ".png"); return root(server).resolve(hash + FILE_EXT);
} }
public static boolean has(MinecraftServer server, String hash) { public static boolean has(MinecraftServer server, String hash) {
return Files.isRegularFile(fileFor(server, hash)); return isValidHash(hash) && Files.isRegularFile(fileFor(server, hash));
} }
/** Read an entire skin blob from disk (header + deflated body). */
public static byte[] read(MinecraftServer server, String hash) throws IOException { public static byte[] read(MinecraftServer server, String hash) throws IOException {
if (!isValidHash(hash)) throw new IOException("invalid hash");
return Files.readAllBytes(fileFor(server, hash)); return Files.readAllBytes(fileFor(server, hash));
} }
/** /**
* Accept one chunk from a player. Returns the finalized {@link SkinData} with the * Validate the magic + parse the {@code (u16 w, u16 h)} header off a blob. Returns
* computed hash once the last chunk lands, or {@code null} if more chunks are expected * {@code [w, h]} on success, or {@code null} if the magic doesn't match or the blob
* (or the upload was rejected check logs). * is too short. No trust extended to the deflated body until this passes.
*/
public static int[] readHeader(byte[] blob) {
if (blob == null || blob.length < HEADER_SIZE) return null;
for (int i = 0; i < MAGIC.length; i++) {
if (blob[i] != MAGIC[i]) return null;
}
int w = ((blob[4] & 0xFF) << 8) | (blob[5] & 0xFF);
int h = ((blob[6] & 0xFF) << 8) | (blob[7] & 0xFF);
return new int[] { w, h };
}
/**
* Accept one chunk of a deflated-RGBA upload from a player. Returns the finalized
* {@link SkinData} once the last chunk lands, or {@code null} if more chunks are expected
* or the upload was rejected.
*/ */
public static SkinData acceptChunk( public static SkinData acceptChunk(
MinecraftServer server, UUID playerId, MinecraftServer server, UUID playerId,
int index, int total, SkinModel model, byte[] data int index, int total, int width, int height, SkinModel model, byte[] data
) { ) {
if (total <= 0 || index < 0 || index >= total) { if (total <= 0 || index < 0 || index >= total) {
DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, index, total); DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, index, total);
@ -74,30 +138,35 @@ public final class SkinCache {
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
return null; return null;
} }
if (width <= 0 || height <= 0 || width > MAX_DIM || height > MAX_DIM) {
DecoFashion.LOGGER.warn("Skin upload from {}: bad dims {}x{}", playerId, width, height);
IN_FLIGHT.remove(playerId);
return null;
}
Assembly asm; Assembly asm;
if (index == 0) { if (index == 0) {
asm = new Assembly(); asm = new Assembly();
asm.expectedTotal = total; asm.expectedTotal = total;
asm.nextIndex = 0; asm.nextIndex = 0;
asm.width = width;
asm.height = height;
asm.model = model; asm.model = model;
asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_BYTES)); asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_DEFLATED_BYTES));
IN_FLIGHT.put(playerId, asm); IN_FLIGHT.put(playerId, asm);
} else { } else {
asm = IN_FLIGHT.get(playerId); asm = IN_FLIGHT.get(playerId);
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index) { if (asm == null || asm.expectedTotal != total || asm.nextIndex != index
DecoFashion.LOGGER.warn("Skin upload from {}: out-of-order chunk (got {}/{}, expected {}/{})", || asm.width != width || asm.height != height) {
playerId, index, total, DecoFashion.LOGGER.warn("Skin upload from {}: chunk mismatch at {}/{}", playerId, index, total);
asm == null ? -1 : asm.nextIndex,
asm == null ? -1 : asm.expectedTotal);
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
return null; return null;
} }
} }
try { try {
if (asm.buffer.size() + data.length > MAX_BYTES) { if (asm.buffer.size() + data.length > MAX_DEFLATED_BYTES) {
DecoFashion.LOGGER.warn("Skin upload from {}: exceeds size cap ({})", playerId, MAX_BYTES); DecoFashion.LOGGER.warn("Skin upload from {}: exceeds deflated cap", playerId);
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
return null; return null;
} }
@ -113,28 +182,59 @@ public final class SkinCache {
IN_FLIGHT.remove(playerId); IN_FLIGHT.remove(playerId);
byte[] png = asm.buffer.toByteArray(); byte[] deflated = asm.buffer.toByteArray();
int expectedRaw;
try {
expectedRaw = rgbaByteCount(asm.width, asm.height);
} catch (IllegalArgumentException ex) {
DecoFashion.LOGGER.warn("Skin upload from {}: {}", playerId, ex.getMessage());
return null;
}
byte[] rgba;
try {
rgba = inflateBounded(deflated, expectedRaw);
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Skin upload from {}: inflate failed ({})", playerId, ex.getMessage());
return null;
}
String hash; String hash;
try { try {
hash = sha256Hex(png); hash = sha256HexOfPixels(asm.width, asm.height, rgba);
} catch (NoSuchAlgorithmException ex) { } catch (NoSuchAlgorithmException ex) {
DecoFashion.LOGGER.error("SHA-256 unavailable", ex); DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
return null; return null;
} }
// Library-size gate: if the player already has 5 skins and this hash isn't one of
// them, reject before touching disk. Prevents a malicious client from filling storage
// by streaming arbitrary distinct skins past the per-player cap.
ServerPlayer player = server.getPlayerList().getPlayer(playerId);
if (player == null) {
DecoFashion.LOGGER.warn("Skin upload from {}: player disconnected before finalize", playerId);
return null;
}
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
if (!lib.contains(hash) && lib.isFull()) {
DecoFashion.LOGGER.warn("Skin upload from {}: library full ({}); rejecting {}",
playerId, SkinLibrary.MAX_ENTRIES, hash);
return null;
}
try { try {
Files.createDirectories(root(server)); Files.createDirectories(root(server));
Path target = fileFor(server, hash); Path target = fileFor(server, hash);
if (!Files.isRegularFile(target)) { if (!Files.isRegularFile(target)) {
Files.write(target, png); Files.write(target, buildBlob(asm.width, asm.height, deflated));
} }
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex); DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex);
return null; return null;
} }
DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} bytes={} model={}", DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} dims={}x{} deflated={} model={}",
playerId, hash, png.length, asm.model); playerId, hash, asm.width, asm.height, deflated.length, asm.model);
return new SkinData(hash, asm.model); return new SkinData(hash, asm.model);
} }
@ -147,10 +247,11 @@ public final class SkinCache {
Map<String, Long> out = new HashMap<>(); Map<String, Long> out = new HashMap<>();
Path dir = root(server); Path dir = root(server);
if (!Files.isDirectory(dir)) return out; if (!Files.isDirectory(dir)) return out;
try (var stream = Files.newDirectoryStream(dir, "*.png")) { try (var stream = Files.newDirectoryStream(dir, "*" + FILE_EXT)) {
for (Path p : stream) { for (Path p : stream) {
String name = p.getFileName().toString(); String name = p.getFileName().toString();
String hash = name.substring(0, name.length() - 4); String hash = name.substring(0, name.length() - FILE_EXT.length());
if (!isValidHash(hash)) continue;
try { try {
out.put(hash, Files.size(p)); out.put(hash, Files.size(p));
} catch (IOException ignored) {} } catch (IOException ignored) {}
@ -161,11 +262,84 @@ public final class SkinCache {
return out; return out;
} }
private static String sha256Hex(byte[] in) throws NoSuchAlgorithmException { /** Build a wire-format blob: magic + (u16 w, u16 h) + deflated pixels. */
public static byte[] buildBlob(int width, int height, byte[] deflated) {
byte[] blob = new byte[HEADER_SIZE + deflated.length];
System.arraycopy(MAGIC, 0, blob, 0, MAGIC.length);
blob[4] = (byte) ((width >>> 8) & 0xFF);
blob[5] = (byte) (width & 0xFF);
blob[6] = (byte) ((height >>> 8) & 0xFF);
blob[7] = (byte) (height & 0xFF);
System.arraycopy(deflated, 0, blob, HEADER_SIZE, deflated.length);
return blob;
}
/**
* Compute {@code width * height * 4} in 64-bit arithmetic, rejecting overflow or
* over-budget sizes. Every pixel-buffer allocation and {@link #inflateBounded} call
* should derive its expected size through this helper so a future MAX_DIM bump can't
* silently overflow {@code int} and produce a negative allocation.
*/
public static int rgbaByteCount(int width, int height) {
long n = (long) width * (long) height * 4L;
if (n <= 0 || n > MAX_RAW_BYTES) {
throw new IllegalArgumentException(
"rgba size out of range: " + n + " (dims " + width + "x" + height + ")");
}
return (int) n;
}
public static byte[] inflateBounded(byte[] compressed, int expected) throws IOException {
if (expected <= 0 || expected > MAX_RAW_BYTES) {
throw new IOException("bad expected inflated size: " + expected);
}
Inflater inf = new Inflater();
try {
inf.setInput(compressed);
byte[] out = new byte[expected];
int written = 0;
byte[] scratch = new byte[64 * 1024];
while (true) {
int n;
try {
n = inf.inflate(scratch);
} catch (DataFormatException ex) {
throw new IOException("zlib decode error: " + ex.getMessage());
}
if (n == 0) {
if (inf.finished()) break;
if (inf.needsInput() || inf.needsDictionary()) {
throw new IOException("zlib stream truncated");
}
break;
}
if (written + n > expected) {
throw new IOException("inflated size exceeds pixel budget");
}
System.arraycopy(scratch, 0, out, written, n);
written += n;
}
if (written != expected) {
throw new IOException("inflated size mismatch: " + written + " != " + expected);
}
return out;
} finally {
inf.end();
}
}
/** Hash is computed over {@code [u16 w][u16 h][raw RGBA bytes]} so dedup is stable across
* deflate-level differences between clients. */
static String sha256HexOfPixels(int width, int height, byte[] rgba) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256"); MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] out = md.digest(in); md.update((byte) ((width >>> 8) & 0xFF));
StringBuilder sb = new StringBuilder(out.length * 2); md.update((byte) (width & 0xFF));
for (byte b : out) sb.append(String.format("%02x", b)); md.update((byte) ((height >>> 8) & 0xFF));
md.update((byte) (height & 0xFF));
md.update(rgba);
byte[] digest = md.digest();
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) sb.append(String.format("%02x", b));
return sb.toString(); return sb.toString();
} }
} }

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

@ -2,6 +2,7 @@ package com.razz.dfashion.skin;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.packet.AssignSkin; import com.razz.dfashion.skin.packet.AssignSkin;
import com.razz.dfashion.skin.packet.DeleteSkin;
import com.razz.dfashion.skin.packet.RequestSkin; import com.razz.dfashion.skin.packet.RequestSkin;
import com.razz.dfashion.skin.packet.SkinChunk; import com.razz.dfashion.skin.packet.SkinChunk;
import com.razz.dfashion.skin.packet.UploadSkinChunk; import com.razz.dfashion.skin.packet.UploadSkinChunk;
@ -12,6 +13,7 @@ import net.minecraft.server.level.ServerPlayer;
import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber; 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.event.RegisterPayloadHandlersEvent;
import net.neoforged.neoforge.network.handling.IPayloadContext; import net.neoforged.neoforge.network.handling.IPayloadContext;
import net.neoforged.neoforge.network.registration.PayloadRegistrar; import net.neoforged.neoforge.network.registration.PayloadRegistrar;
@ -21,7 +23,7 @@ import java.io.IOException;
@EventBusSubscriber(modid = DecoFashion.MODID) @EventBusSubscriber(modid = DecoFashion.MODID)
public final class SkinNetwork { public final class SkinNetwork {
private static final String VERSION = "1"; private static final String VERSION = "2";
@SubscribeEvent @SubscribeEvent
static void onRegister(RegisterPayloadHandlersEvent event) { static void onRegister(RegisterPayloadHandlersEvent event) {
@ -36,11 +38,43 @@ public final class SkinNetwork {
registrar.playToServer( registrar.playToServer(
AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin
); );
registrar.playToServer(
DeleteSkin.TYPE, DeleteSkin.STREAM_CODEC, SkinNetwork::onDeleteSkin
);
registrar.playToClient( registrar.playToClient(
SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk
); );
} }
/** Clear any in-flight upload assembly when a player disconnects so their reserved
* buffer (up to {@link SkinCache#MAX_DEFLATED_BYTES}) doesn't leak until GC. Mirrors
* {@link com.razz.dfashion.cosmetic.share.CosmeticShareNetwork}'s handler. */
@SubscribeEvent
static void onPlayerLoggedOut(PlayerEvent.PlayerLoggedOutEvent event) {
if (event.getEntity() instanceof ServerPlayer sp) {
SkinCache.cancelInFlight(sp.getUUID());
}
}
private static void onDeleteSkin(DeleteSkin msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return;
if (!SkinCache.isValidHash(msg.hash())) {
DecoFashion.LOGGER.warn("DeleteSkin from {}: invalid hash rejected", player.getUUID());
return;
}
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
if (!lib.contains(msg.hash())) return;
player.setData(SkinAttachments.LIBRARY.get(), lib.remove(msg.hash()));
// If the deleted skin was active, reset to vanilla.
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && msg.hash().equals(current.hash())) {
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
}
DecoFashion.LOGGER.info("Skin delete: player={} hash={}", player.getUUID(), msg.hash());
});
}
private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) { private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> { ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return; if (!(ctx.player() instanceof ServerPlayer player)) return;
@ -48,9 +82,22 @@ public final class SkinNetwork {
if (server == null) return; if (server == null) return;
SkinData finalized = SkinCache.acceptChunk( SkinData finalized = SkinCache.acceptChunk(
server, player.getUUID(), msg.index(), msg.total(), msg.model(), msg.data() server, player.getUUID(),
msg.index(), msg.total(),
msg.width(), msg.height(),
msg.model(), msg.data()
); );
if (finalized != null) { if (finalized != null) {
SkinLibrary lib = player.getData(SkinAttachments.LIBRARY.get());
if (!lib.contains(finalized.hash())) {
int n = lib.entries().size() + 1;
SkinLibraryEntry entry = new SkinLibraryEntry(
finalized.hash(),
"Skin " + n,
System.currentTimeMillis()
);
player.setData(SkinAttachments.LIBRARY.get(), lib.add(entry));
}
player.setData(SkinAttachments.DATA.get(), finalized); player.setData(SkinAttachments.DATA.get(), finalized);
} }
}); });
@ -66,6 +113,10 @@ public final class SkinNetwork {
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY); player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
return; return;
} }
if (!SkinCache.isValidHash(msg.hash())) {
DecoFashion.LOGGER.warn("AssignSkin from {}: invalid hash rejected", player.getUUID());
return;
}
if (!SkinCache.has(server, msg.hash())) { if (!SkinCache.has(server, msg.hash())) {
DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash()); DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash());
return; return;
@ -79,46 +130,62 @@ public final class SkinNetwork {
if (!(ctx.player() instanceof ServerPlayer player)) return; if (!(ctx.player() instanceof ServerPlayer player)) return;
MinecraftServer server = player.level().getServer(); MinecraftServer server = player.level().getServer();
if (server == null) return; if (server == null) return;
if (!SkinCache.isValidHash(msg.hash())) {
DecoFashion.LOGGER.warn("Skin request from {}: invalid hash rejected", player.getUUID());
return;
}
if (!SkinCache.has(server, msg.hash())) { if (!SkinCache.has(server, msg.hash())) {
DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash()); DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash());
return; return;
} }
byte[] png; byte[] blob;
try { try {
png = SkinCache.read(server, msg.hash()); blob = SkinCache.read(server, msg.hash());
} catch (IOException ex) { } catch (IOException ex) {
DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex); DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex);
return; return;
} }
int[] dims = SkinCache.readHeader(blob);
if (dims == null || blob.length <= 4) {
DecoFashion.LOGGER.error("Skin request {}: blob missing header", msg.hash());
return;
}
int width = dims[0];
int height = dims[1];
int bodyLen = blob.length - 4;
int chunkSize = SkinCache.MAX_CHUNK_BYTES; int chunkSize = SkinCache.MAX_CHUNK_BYTES;
int total = Math.max(1, (png.length + chunkSize - 1) / chunkSize); int total = Math.max(1, (bodyLen + chunkSize - 1) / chunkSize);
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
int off = i * chunkSize; int off = 4 + i * chunkSize;
int len = Math.min(chunkSize, png.length - off); int len = Math.min(chunkSize, blob.length - off);
byte[] slice = new byte[len]; byte[] slice = new byte[len];
System.arraycopy(png, off, slice, 0, len); System.arraycopy(blob, off, slice, 0, len);
player.connection.send(new ClientboundCustomPayloadPacket( player.connection.send(new ClientboundCustomPayloadPacket(
new SkinChunk(msg.hash(), i, total, slice))); new SkinChunk(msg.hash(), i, total, width, height, slice)));
} }
}); });
} }
private static void onSkinChunk(SkinChunk msg, IPayloadContext ctx) { private static void onSkinChunk(SkinChunk msg, IPayloadContext ctx) {
// Defer client-side handling so we don't pull client classes into common code.
ctx.enqueueWork(() -> ClientSkinChunkReceiver.accept(msg)); ctx.enqueueWork(() -> ClientSkinChunkReceiver.accept(msg));
} }
/** /**
* Small trampoline to keep the client-only receiver out of the common-side packet router; * Small trampoline to keep the client-only receiver out of the common-side packet router;
* the {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class. * {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class.
*/ */
private static final class ClientSkinChunkReceiver { private static final class ClientSkinChunkReceiver {
static void accept(SkinChunk msg) { static void accept(SkinChunk msg) {
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return; if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
if (!SkinCache.isValidHash(msg.hash())) {
DecoFashion.LOGGER.warn("Received SkinChunk with invalid hash; ignoring");
return;
}
com.razz.dfashion.client.ClientSkinCache.onChunk( com.razz.dfashion.client.ClientSkinCache.onChunk(
msg.hash(), msg.index(), msg.total(), msg.data() msg.hash(), msg.index(), msg.total(),
msg.width(), msg.height(), msg.data()
); );
} }
} }

@ -18,10 +18,13 @@ public record AssignSkin(String hash, SkinModel model) implements CustomPacketPa
public static final Type<AssignSkin> TYPE = public static final Type<AssignSkin> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin")); new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin"));
/** SHA-256 hex is exactly 64 chars; empty string (= "clear skin") also fits. */
private static final int MAX_HASH_LEN = 64;
public static final StreamCodec<FriendlyByteBuf, AssignSkin> STREAM_CODEC = public static final StreamCodec<FriendlyByteBuf, AssignSkin> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, AssignSkin::hash, ByteBufCodecs.stringUtf8(MAX_HASH_LEN), AssignSkin::hash,
SkinModel.STREAM_CODEC, AssignSkin::model, SkinModel.STREAM_CODEC, AssignSkin::model,
AssignSkin::new AssignSkin::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;
}
}

@ -19,7 +19,7 @@ public record RequestSkin(String hash) implements CustomPacketPayload {
public static final StreamCodec<FriendlyByteBuf, RequestSkin> STREAM_CODEC = public static final StreamCodec<FriendlyByteBuf, RequestSkin> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RequestSkin::hash, ByteBufCodecs.stringUtf8(64), RequestSkin::hash,
RequestSkin::new RequestSkin::new
); );

@ -1,6 +1,7 @@
package com.razz.dfashion.skin.packet; package com.razz.dfashion.skin.packet;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SkinCache;
import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.FriendlyByteBuf;
import net.minecraft.network.codec.ByteBufCodecs; import net.minecraft.network.codec.ByteBufCodecs;
@ -9,9 +10,14 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
/** /**
* Server client. One chunk of the PNG bytes for a requested skin hash. * Server client. One chunk of a stored skin's <b>deflated RGBA pixels</b>, not PNG bytes.
*
* <p>Mirrors {@link UploadSkinChunk}'s shape in reverse. Receivers reassemble the deflated
* buffer, inflate it under a bounded cumulative output cap (= {@code width*height*4}), and
* build a {@code NativeImage} directly via {@code setPixelABGR} no STB call anywhere in
* the download path.
*/ */
public record SkinChunk(String hash, int index, int total, byte[] data) public record SkinChunk(String hash, int index, int total, int width, int height, byte[] data)
implements CustomPacketPayload { implements CustomPacketPayload {
public static final Type<SkinChunk> TYPE = public static final Type<SkinChunk> TYPE =
@ -19,10 +25,12 @@ public record SkinChunk(String hash, int index, int total, byte[] data)
public static final StreamCodec<FriendlyByteBuf, SkinChunk> STREAM_CODEC = public static final StreamCodec<FriendlyByteBuf, SkinChunk> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, SkinChunk::hash, ByteBufCodecs.stringUtf8(64), SkinChunk::hash,
ByteBufCodecs.VAR_INT, SkinChunk::index, ByteBufCodecs.VAR_INT, SkinChunk::index,
ByteBufCodecs.VAR_INT, SkinChunk::total, ByteBufCodecs.VAR_INT, SkinChunk::total,
ByteBufCodecs.BYTE_ARRAY, SkinChunk::data, ByteBufCodecs.VAR_INT, SkinChunk::width,
ByteBufCodecs.VAR_INT, SkinChunk::height,
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES),SkinChunk::data,
SkinChunk::new SkinChunk::new
); );

@ -1,6 +1,7 @@
package com.razz.dfashion.skin.packet; package com.razz.dfashion.skin.packet;
import com.razz.dfashion.DecoFashion; import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SkinCache;
import com.razz.dfashion.skin.SkinModel; import com.razz.dfashion.skin.SkinModel;
import net.minecraft.network.FriendlyByteBuf; import net.minecraft.network.FriendlyByteBuf;
@ -10,11 +11,16 @@ import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
/** /**
* Client server. One chunk of a skin upload. When {@code index + 1 == total}, * Client server. One chunk of a skin upload as <b>deflated RGBA pixels</b>, not PNG bytes.
* the server finalizes, hashes, writes to cache, and updates the player's SkinData. *
* Chunk data is raw PNG bytes; the server reassembles in order. * <p>The uploading client parses its source PNG locally with {@code SafePngReader} (pure Java,
* no STB), extracts raw RGBA, deflates it, and streams the deflated buffer in fixed-size chunks.
* {@code w} and {@code h} are repeated on every chunk so reassembly can enforce them before
* touching the payload; server validates that subsequent chunks carry the same dimensions as
* the first. No PNG container ever crosses the wire polyglot/iCCP/trailing-IDAT attacks
* can't exist when the attack surface is two u16s and a deflate stream.
*/ */
public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data) public record UploadSkinChunk(int index, int total, int width, int height, SkinModel model, byte[] data)
implements CustomPacketPayload { implements CustomPacketPayload {
public static final Type<UploadSkinChunk> TYPE = public static final Type<UploadSkinChunk> TYPE =
@ -22,10 +28,12 @@ public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data
public static final StreamCodec<FriendlyByteBuf, UploadSkinChunk> STREAM_CODEC = public static final StreamCodec<FriendlyByteBuf, UploadSkinChunk> STREAM_CODEC =
StreamCodec.composite( StreamCodec.composite(
ByteBufCodecs.VAR_INT, UploadSkinChunk::index, ByteBufCodecs.VAR_INT, UploadSkinChunk::index,
ByteBufCodecs.VAR_INT, UploadSkinChunk::total, ByteBufCodecs.VAR_INT, UploadSkinChunk::total,
SkinModel.STREAM_CODEC, UploadSkinChunk::model, ByteBufCodecs.VAR_INT, UploadSkinChunk::width,
ByteBufCodecs.BYTE_ARRAY, UploadSkinChunk::data, ByteBufCodecs.VAR_INT, UploadSkinChunk::height,
SkinModel.STREAM_CODEC, UploadSkinChunk::model,
ByteBufCodecs.byteArray(SkinCache.MAX_CHUNK_BYTES), UploadSkinChunk::data,
UploadSkinChunk::new UploadSkinChunk::new
); );

@ -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…
Cancel
Save