diff --git a/.gitignore b/.gitignore
index fee2f7b..95f6130 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,4 @@ run/
**/src/generated/**/.cache/
repo/
!**/src/**/repo/
+.claude/
diff --git a/src/main/java/com/razz/dfashion/DecoFashion.java b/src/main/java/com/razz/dfashion/DecoFashion.java
index a0e1230..accca66 100644
--- a/src/main/java/com/razz/dfashion/DecoFashion.java
+++ b/src/main/java/com/razz/dfashion/DecoFashion.java
@@ -10,6 +10,7 @@ import com.razz.dfashion.skin.SkinAttachments;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer;
import net.neoforged.fml.common.Mod;
+import net.neoforged.fml.config.ModConfig;
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
@Mod(DecoFashion.MODID)
@@ -22,6 +23,11 @@ public class DecoFashion {
CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus);
SkinAttachments.ATTACHMENT_TYPES.register(modEventBus);
ClosetRegistry.register(modEventBus);
+ // SERVER type = loaded on server, auto-synced to connected clients on login.
+ modContainer.registerConfig(ModConfig.Type.SERVER, DecoFashionConfig.SPEC);
+ // Runtime-reload: when an op edits the TOML mid-session, strip shared cosmetics if
+ // the shared-library feature was just disabled. Also fires on initial load.
+ modEventBus.addListener(DecoFashionConfig::onConfigChange);
}
private void commonSetup(FMLCommonSetupEvent event) {
diff --git a/src/main/java/com/razz/dfashion/DecoFashionClient.java b/src/main/java/com/razz/dfashion/DecoFashionClient.java
index 71b4e7f..ca7dafe 100644
--- a/src/main/java/com/razz/dfashion/DecoFashionClient.java
+++ b/src/main/java/com/razz/dfashion/DecoFashionClient.java
@@ -10,6 +10,7 @@ import com.razz.dfashion.block.ClosetRegistry;
import com.razz.dfashion.client.ClientSharedCosmeticCache;
import com.razz.dfashion.client.ClientSkinCache;
import com.razz.dfashion.client.ClosetModelCache;
+import com.razz.dfashion.client.ClosetPreferences;
import com.razz.dfashion.client.ClosetRenderer;
import com.razz.dfashion.client.CosmeticCache;
import com.razz.dfashion.client.CosmeticRenderLayer;
@@ -58,6 +59,7 @@ public class DecoFashionClient {
@SubscribeEvent
static void onClientSetup(FMLClientSetupEvent event) {
+ ClosetPreferences.load();
DecoFashion.LOGGER.info("DecoFashion client setup complete");
}
diff --git a/src/main/java/com/razz/dfashion/DecoFashionConfig.java b/src/main/java/com/razz/dfashion/DecoFashionConfig.java
new file mode 100644
index 0000000..54a7713
--- /dev/null
+++ b/src/main/java/com/razz/dfashion/DecoFashionConfig.java
@@ -0,0 +1,110 @@
+package com.razz.dfashion;
+
+import com.razz.dfashion.cosmetic.CosmeticAttachments;
+import com.razz.dfashion.cosmetic.CosmeticRef;
+
+import net.minecraft.server.MinecraftServer;
+import net.minecraft.server.level.ServerPlayer;
+import net.neoforged.fml.event.config.ModConfigEvent;
+import net.neoforged.neoforge.common.ModConfigSpec;
+import net.neoforged.neoforge.server.ServerLifecycleHooks;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Server-owner opt-in flags. Lives at {@code config/decofashion-server.toml} and is
+ * automatically synced to connected clients on login (ModConfig.Type.SERVER).
+ *
+ *
Both features default to {@code false} so operators must opt in explicitly.
+ */
+public final class DecoFashionConfig {
+
+ public static final ModConfigSpec SPEC;
+ public static final ModConfigSpec.BooleanValue SHARED_LIBRARY_ENABLED;
+ public static final ModConfigSpec.BooleanValue BODY_HIDE_ENABLED;
+ public static final ModConfigSpec.IntValue COSMETICS_PER_PLAYER;
+ public static final ModConfigSpec.IntValue SKINS_PER_PLAYER;
+
+ static {
+ ModConfigSpec.Builder b = new ModConfigSpec.Builder();
+
+ b.comment("Decofashion server-owner feature toggles and limits.",
+ "Boolean features default to OFF — set a flag to true to opt in.");
+
+ SHARED_LIBRARY_ENABLED = b
+ .comment(
+ "Allow players to upload and equip shared cosmetics from the Library tab.",
+ "The server rejects upload and assign-shared packets while this is false,",
+ "and shared cosmetics already equipped will unequip on the next join.",
+ "Clients hide the Library tab + Share button when disabled.")
+ .define("sharedLibraryEnabled", false);
+
+ BODY_HIDE_ENABLED = b
+ .comment(
+ "Allow clients to hide their own player body parts (head/body/arms/legs) via",
+ "the closet-screen toggles. Enforcement is good-faith — a modified client can",
+ "still hide parts locally. Clients hide the H/B/A/L toggle buttons when",
+ "disabled and render all body parts as vanilla does.")
+ .define("bodyHideEnabled", false);
+
+ b.push("limits");
+
+ COSMETICS_PER_PLAYER = b
+ .comment(
+ "Maximum number of shared cosmetics a single player can keep in their",
+ "personal library. New uploads past this cap are rejected. Existing entries",
+ "past the cap continue to work until removed.")
+ .defineInRange("cosmeticsPerPlayer", 15, 0, 1000);
+
+ SKINS_PER_PLAYER = b
+ .comment(
+ "Maximum number of skins a single player can keep in their personal skin",
+ "library. Same semantics as cosmeticsPerPlayer.")
+ .defineInRange("skinsPerPlayer", 5, 0, 1000);
+
+ b.pop();
+
+ SPEC = b.build();
+ }
+
+ private DecoFashionConfig() {}
+
+ /**
+ * Runtime-reload hook. Registered against the mod event bus in {@link DecoFashion}.
+ * Fires on both Loading (initial startup) and Reloading (operator edited the file).
+ * When SHARED_LIBRARY_ENABLED is now false, strip all online players' equipped Shared
+ * cosmetics on the logical server so players don't see broken refs.
+ */
+ public static void onConfigChange(ModConfigEvent event) {
+ if (event.getConfig().getSpec() != SPEC) return;
+ // Unloading fires on server shutdown AFTER the config values are torn down —
+ // any .get() here throws. Nothing to do on shutdown anyway.
+ if (event instanceof ModConfigEvent.Unloading) return;
+ DecoFashion.LOGGER.info(
+ "DecoFashion config {}: sharedLibrary={}, bodyHide={}",
+ event instanceof ModConfigEvent.Reloading ? "reloaded" : "loaded",
+ SHARED_LIBRARY_ENABLED.get(), BODY_HIDE_ENABLED.get());
+
+ MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
+ if (server == null) return;
+ if (SHARED_LIBRARY_ENABLED.get()) return;
+
+ // Shared library disabled — strip Shared refs from every online player.
+ for (ServerPlayer p : server.getPlayerList().getPlayers()) {
+ Map equipped = p.getData(CosmeticAttachments.EQUIPPED.get());
+ Map cleaned = null;
+ for (Map.Entry e : equipped.entrySet()) {
+ if (e.getValue() instanceof CosmeticRef.Shared) {
+ if (cleaned == null) cleaned = new HashMap<>(equipped);
+ cleaned.remove(e.getKey());
+ }
+ }
+ if (cleaned != null) {
+ p.setData(CosmeticAttachments.EQUIPPED.get(), cleaned);
+ DecoFashion.LOGGER.info("Runtime unequip shared for {}: feature disabled",
+ p.getUUID());
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/razz/dfashion/client/ClosetPreferences.java b/src/main/java/com/razz/dfashion/client/ClosetPreferences.java
new file mode 100644
index 0000000..be2d808
--- /dev/null
+++ b/src/main/java/com/razz/dfashion/client/ClosetPreferences.java
@@ -0,0 +1,72 @@
+package com.razz.dfashion.client;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.razz.dfashion.DecoFashion;
+
+import net.neoforged.fml.loading.FMLPaths;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.io.Writer;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Client-side toggles for hiding player body parts so cosmetics don't clip
+ * through the vanilla skin. Persisted as JSON in
+ * {@code config/decofashion_prefs.json}.
+ */
+public class ClosetPreferences {
+
+ public static boolean hideHead = false;
+ public static boolean hideBody = false;
+ public static boolean hideArms = false;
+ public static boolean hideLegs = false;
+
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
+
+ private static Path file() {
+ return FMLPaths.CONFIGDIR.get().resolve("decofashion_prefs.json");
+ }
+
+ public static void load() {
+ Path p = file();
+ if (!Files.exists(p)) return;
+ try (Reader r = Files.newBufferedReader(p)) {
+ Data d = GSON.fromJson(r, Data.class);
+ if (d != null) {
+ hideHead = d.hideHead;
+ hideBody = d.hideBody;
+ hideArms = d.hideArms;
+ hideLegs = d.hideLegs;
+ }
+ } catch (IOException e) {
+ DecoFashion.LOGGER.warn("Failed to load decofashion prefs", e);
+ }
+ }
+
+ public static void save() {
+ Path p = file();
+ try {
+ if (p.getParent() != null) Files.createDirectories(p.getParent());
+ try (Writer w = Files.newBufferedWriter(p)) {
+ Data d = new Data();
+ d.hideHead = hideHead;
+ d.hideBody = hideBody;
+ d.hideArms = hideArms;
+ d.hideLegs = hideLegs;
+ GSON.toJson(d, w);
+ }
+ } catch (IOException e) {
+ DecoFashion.LOGGER.warn("Failed to save decofashion prefs", e);
+ }
+ }
+
+ private static class Data {
+ boolean hideHead;
+ boolean hideBody;
+ boolean hideArms;
+ boolean hideLegs;
+ }
+}
diff --git a/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java b/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java
index 99d30f6..4526dde 100644
--- a/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java
+++ b/src/main/java/com/razz/dfashion/client/CosmeticRenderLayer.java
@@ -91,7 +91,7 @@ public class CosmeticRenderLayer extends RenderLayer localCache
) {
return switch (ref) {
@@ -131,7 +131,7 @@ public class CosmeticRenderLayer extends RenderLayer snake_case lowercase. */
- private static String normalize(String name) {
+ static String normalize(String name) {
StringBuilder sb = new StringBuilder(name.length() + 4);
for (int i = 0; i < name.length(); i++) {
char c = name.charAt(i);
diff --git a/src/main/java/com/razz/dfashion/client/FirstPersonArmCosmeticRenderer.java b/src/main/java/com/razz/dfashion/client/FirstPersonArmCosmeticRenderer.java
new file mode 100644
index 0000000..7e903b6
--- /dev/null
+++ b/src/main/java/com/razz/dfashion/client/FirstPersonArmCosmeticRenderer.java
@@ -0,0 +1,100 @@
+package com.razz.dfashion.client;
+
+import com.mojang.blaze3d.vertex.PoseStack;
+import com.razz.dfashion.DecoFashion;
+import com.razz.dfashion.cosmetic.CosmeticAttachments;
+import com.razz.dfashion.cosmetic.CosmeticRef;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.model.geom.ModelPart;
+import net.minecraft.client.model.player.PlayerModel;
+import net.minecraft.client.player.AbstractClientPlayer;
+import net.minecraft.client.renderer.SubmitNodeCollector;
+import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
+import net.minecraft.client.renderer.entity.EntityRenderer;
+import net.minecraft.client.renderer.entity.player.AvatarRenderer;
+import net.minecraft.client.renderer.rendertype.RenderTypes;
+import net.minecraft.client.renderer.texture.OverlayTexture;
+import net.minecraft.resources.Identifier;
+import net.minecraft.world.entity.HumanoidArm;
+import net.neoforged.api.distmarker.Dist;
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.neoforge.client.event.RenderArmEvent;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * Renders arm/sleeve cosmetics on the local player during first-person arm rendering.
+ * Subscribes to {@link RenderArmEvent} (fires before vanilla {@code renderHand} submits
+ * the arm mesh). Non-cancelling so the vanilla arm still draws — cosmetics layer on top.
+ *
+ *
Full-body first-person is intentionally out of scope; head/body/legs aren't visible
+ * in first-person anyway. Arms/sleeves are the only bones that show up.
+ */
+@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
+public class FirstPersonArmCosmeticRenderer {
+
+ private static final Set RIGHT_ARM_BONES = Set.of("right_arm", "right_sleeve");
+ private static final Set LEFT_ARM_BONES = Set.of("left_arm", "left_sleeve");
+
+ @SubscribeEvent
+ static void onRenderArm(RenderArmEvent event) {
+ Minecraft mc = Minecraft.getInstance();
+ AbstractClientPlayer player = event.getPlayer();
+ if (player != mc.player) return;
+
+ // Vanilla renderHand explicitly sets arm.visible = true before submitting, so the
+ // mixin's hide doesn't carry into first-person. Cancelling the event skips vanilla's
+ // submit entirely; our cosmetic arm parts (below) still render on top.
+ if (com.razz.dfashion.DecoFashionConfig.BODY_HIDE_ENABLED.get()
+ && com.razz.dfashion.client.ClosetPreferences.hideArms) {
+ event.setCanceled(true);
+ }
+
+ Map equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
+ if (equipped.isEmpty()) return;
+
+ EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
+ EntityRenderer, ?> renderer = dispatcher.getRenderer(player);
+ if (!(renderer instanceof AvatarRenderer> avatarRenderer)) return;
+ PlayerModel model = avatarRenderer.getModel();
+
+ HumanoidArm arm = event.getArm();
+ ModelPart bone = arm == HumanoidArm.RIGHT ? model.rightArm : model.leftArm;
+ Set validBones = arm == HumanoidArm.RIGHT ? RIGHT_ARM_BONES : LEFT_ARM_BONES;
+
+ // Match vanilla's first-person arm pose (see AvatarRenderer.renderHand) so cosmetic
+ // attaches to the same arm orientation the arm mesh will render with.
+ bone.resetPose();
+ bone.zRot = arm == HumanoidArm.RIGHT ? 0.1f : -0.1f;
+
+ PoseStack poseStack = event.getPoseStack();
+ SubmitNodeCollector collector = event.getSubmitNodeCollector();
+ int light = event.getPackedLight();
+ Map cache = CosmeticCache.cosmetics;
+
+ for (CosmeticRef ref : equipped.values()) {
+ CosmeticCache.Baked cosmetic = CosmeticRenderLayer.resolve(ref, cache);
+ if (cosmetic == null) continue;
+
+ for (Map.Entry entry : cosmetic.parts().entrySet()) {
+ if (!validBones.contains(CosmeticRenderLayer.normalize(entry.getKey()))) continue;
+
+ poseStack.pushPose();
+ bone.translateAndRotate(poseStack);
+ poseStack.scale(-1f, -1f, 1f);
+ collector.submitModelPart(
+ entry.getValue(),
+ poseStack,
+ RenderTypes.entityTranslucent(cosmetic.texture()),
+ light,
+ OverlayTexture.NO_OVERLAY,
+ null
+ );
+ poseStack.popPose();
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java
index 6063051..37c5c77 100644
--- a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java
+++ b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java
@@ -1,10 +1,12 @@
package com.razz.dfashion.client.screen;
+import com.razz.dfashion.DecoFashionConfig;
import com.razz.dfashion.block.ClosetBlockEntity;
import com.razz.dfashion.client.ClientCosmeticInfoCache;
import com.razz.dfashion.client.ClientSharedCosmeticCache;
import com.razz.dfashion.client.ClientSharedCosmeticUploader;
import com.razz.dfashion.client.ClientSkinCache;
+import com.razz.dfashion.client.ClosetPreferences;
import com.razz.dfashion.client.CosmeticCache;
import com.razz.dfashion.client.CosmeticRenderLayer;
import com.razz.dfashion.client.SkinInfoOverride;
@@ -43,6 +45,7 @@ import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
import net.minecraft.client.renderer.entity.EntityRenderer;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.entity.state.EntityRenderState;
+import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.LivingEntity;
@@ -75,15 +78,28 @@ import java.util.Map;
public class ClosetScreen extends Screen {
- // 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.
- // "shared" is a pseudo-category for the shared-cosmetic library (vertical scroll grid).
- private static final String[] CATEGORIES = {
- "hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin", "shared"
- };
+ // Tab vocabulary — matches category memory. Top-level tabs collapse the old per-category
+ // list into bone-group tabs + three standalones ("particle", "skin", "shared"). Selecting
+ // a bone-group tab exposes a sub-filter pill row so the author can narrow within the group.
+ // "skin" is a pseudo-category that drives the per-player skin override UI rather than the
+ // usual cosmetic row; "shared" is the shared-cosmetic library (vertical scroll grid).
private static final String SKIN_TAB = "skin";
private static final String SHARED_TAB = "shared";
+ /** Bone-group tab key → sub-categories it shows. Display order = insertion order. */
+ private static final java.util.LinkedHashMap BONE_GROUPS = new java.util.LinkedHashMap<>();
+ static {
+ BONE_GROUPS.put("head", new String[]{"hat", "head"});
+ BONE_GROUPS.put("torso", new String[]{"torso", "wings"});
+ BONE_GROUPS.put("arms", new String[]{"arms", "wrist"});
+ BONE_GROUPS.put("legs", new String[]{"legs", "feet"});
+ }
+
+ /** Top-level tabs in render order: 4 bone groups + particle + skin + shared. */
+ private static final String[] TOP_LEVEL_TABS = {
+ "head", "torso", "arms", "legs", "particle", SKIN_TAB, SHARED_TAB
+ };
+
/** Fallback equip category used when a shared cosmetic has no canonical bones. */
private static final String FALLBACK_SHARED_CATEGORY = "particle";
@@ -121,6 +137,7 @@ public class ClosetScreen extends Screen {
private CameraType previousCamera;
private @Nullable Button leftArrow, rightArrow;
private @Nullable Button shareButton;
+ private @Nullable Button hideHeadBtn, hideBodyBtn, hideArmsBtn, hideLegsBtn;
// Body/head yaw applied every tick. yRot (camera-facing yaw) is left untouched so
// rotating doesn't pan the camera — only the player's visible body/head turn.
private float displayYaw = 0f;
@@ -128,6 +145,10 @@ public class ClosetScreen extends Screen {
private @Nullable String selectedCategory;
private int cosmeticScroll = 0;
private final List