add server owner config opt ins, body hide toggles, first person arm cosmetics, bone group tabs

main
MomokoKoigakubo 4 weeks ago
parent b5503545b3
commit 640dcae487

1
.gitignore vendored

@ -38,3 +38,4 @@ run/
**/src/generated/**/.cache/
repo/
!**/src/**/repo/
.claude/

@ -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) {

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

@ -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).
*
* <p>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<String, CosmeticRef> equipped = p.getData(CosmeticAttachments.EQUIPPED.get());
Map<String, CosmeticRef> cleaned = null;
for (Map.Entry<String, CosmeticRef> 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());
}
}
}
}

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

@ -91,7 +91,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
}
}
private static CosmeticCache.Baked resolve(
static CosmeticCache.Baked resolve(
CosmeticRef ref, Map<Identifier, CosmeticCache.Baked> localCache
) {
return switch (ref) {
@ -131,7 +131,7 @@ public class CosmeticRenderLayer extends RenderLayer<AvatarRenderState, PlayerMo
}
/** PascalCase / camelCase / UPPER -> 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);

@ -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.
*
* <p>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<String> RIGHT_ARM_BONES = Set.of("right_arm", "right_sleeve");
private static final Set<String> 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<String, CosmeticRef> 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<String> 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<Identifier, CosmeticCache.Baked> cache = CosmeticCache.cosmetics;
for (CosmeticRef ref : equipped.values()) {
CosmeticCache.Baked cosmetic = CosmeticRenderLayer.resolve(ref, cache);
if (cosmetic == null) continue;
for (Map.Entry<String, ModelPart> 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();
}
}
}
}

@ -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<String, String[]> 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<Button> cosmeticButtons = new ArrayList<>();
/** Bone-group sub-filter pill buttons. Tracked separately from cosmeticButtons so the
* preview renderer, which pairs cosmeticButtons with catalog entries by index, never
* accidentally draws a 3D mini-player on top of a pill. Cleaned up on every rebuild. */
private final List<Button> filterPillButtons = new ArrayList<>();
private boolean showPreviews = true; // toggled by the bottom-right button
// Current skin-model selection on the skin tab. Drives the Upload button's model arg
@ -175,6 +196,12 @@ public class ClosetScreen extends Screen {
// ---- Shared-library tab state ----
/** {@code null} = no filter (show all); else one of {@link #SHARED_FILTER_KEYS}. */
private @Nullable String sharedBoneFilter = null;
/** Sub-category pill inside the selected bone-group tab. {@code null} = show all in group. */
private @Nullable String selectedSubFilter = null;
/** Last observed values of server-synced feature flags; used by {@link #tick()} to
* detect runtime config changes and rebuild the closet UI in response. */
private boolean lastSharedEnabled;
private boolean lastBodyHideEnabled;
private int sharedVerticalScroll = 0;
private final Map<Button, String> sharedButtonHashes = new HashMap<>();
private int lastKnownSharedCount = -1;
@ -193,7 +220,9 @@ public class ClosetScreen extends Screen {
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;
/** +25 below the filter bar (was +10) so the per-cosmetic {@code ?}/{@code x} overlay
* buttons at {@code boxY - 12} don't crowd the filter pills. */
private static final int SHARED_GRID_TOP = SHARED_FILTER_Y + SHARED_FILTER_H + 25;
/** Height reserved for bottom buttons; the grid stops above this. */
private static final int SHARED_GRID_BOTTOM_PAD = 50;
@ -220,7 +249,7 @@ public class ClosetScreen extends Screen {
NeoForge.EVENT_BUS.register(this);
if (selectedCategory == null) selectedCategory = CATEGORIES[0];
if (selectedCategory == null) selectedCategory = TOP_LEVEL_TABS[0];
// Catch any PNGs added to the cache directory since last client setup —
// e.g. user dropped files in manually, or the folder was just populated.
@ -232,14 +261,25 @@ public class ClosetScreen extends Screen {
return;
}
lastSharedEnabled = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get();
lastBodyHideEnabled = DecoFashionConfig.BODY_HIDE_ENABLED.get();
buildTabs();
buildPartToggles();
buildBottomControls();
selectCategory(selectedCategory != null ? selectedCategory : CATEGORIES[0]);
// If the server disabled shared library mid-session and the player was viewing the
// Library tab, fall back to the first available tab.
String target = selectedCategory != null ? selectedCategory : TOP_LEVEL_TABS[0];
if (SHARED_TAB.equals(target) && !DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
target = TOP_LEVEL_TABS[0];
}
selectCategory(target);
}
private void buildTabs() {
int x = TAB_X_START;
for (String cat : CATEGORIES) {
for (String cat : TOP_LEVEL_TABS) {
// Server disabled shared library → hide the Library tab entirely.
if (SHARED_TAB.equals(cat) && !DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) continue;
String label = tabLabel(cat);
addRenderableWidget(Button.builder(
Component.literal(label),
@ -249,6 +289,53 @@ public class ClosetScreen extends Screen {
}
}
/** Body-part-hide toggles, placed to the right of the tab row. Each button cycles the
* corresponding {@link ClosetPreferences} flag and persists to disk. A struck-through
* red label indicates the part is currently hidden. Skipped entirely when the server
* disables body-hide via the mod config. */
private void buildPartToggles() {
if (!DecoFashionConfig.BODY_HIDE_ENABLED.get()) return;
int startX = TAB_X_START + TOP_LEVEL_TABS.length * (TAB_W + TAB_SPACING) + 10;
int step = TAB_W + TAB_SPACING;
hideHeadBtn = makeToggle("H", "Head", startX, ClosetPreferences.hideHead,
b -> { ClosetPreferences.hideHead = !ClosetPreferences.hideHead;
refreshToggle(b, "H", ClosetPreferences.hideHead); });
hideBodyBtn = makeToggle("B", "Body", startX + step, ClosetPreferences.hideBody,
b -> { ClosetPreferences.hideBody = !ClosetPreferences.hideBody;
refreshToggle(b, "B", ClosetPreferences.hideBody); });
hideArmsBtn = makeToggle("A", "Arms", startX + 2*step, ClosetPreferences.hideArms,
b -> { ClosetPreferences.hideArms = !ClosetPreferences.hideArms;
refreshToggle(b, "A", ClosetPreferences.hideArms); });
hideLegsBtn = makeToggle("L", "Legs", startX + 3*step, ClosetPreferences.hideLegs,
b -> { ClosetPreferences.hideLegs = !ClosetPreferences.hideLegs;
refreshToggle(b, "L", ClosetPreferences.hideLegs); });
addRenderableWidget(hideHeadBtn);
addRenderableWidget(hideBodyBtn);
addRenderableWidget(hideArmsBtn);
addRenderableWidget(hideLegsBtn);
}
private Button makeToggle(String letter, String tooltip, int x, boolean hidden, Button.OnPress onPress) {
Button b = Button.builder(partToggleLabel(letter, hidden), onPress)
.bounds(x, TAB_Y, TAB_W, TAB_H)
.build();
b.setTooltip(Tooltip.create(Component.literal("Hide " + tooltip)));
return b;
}
private void refreshToggle(Button btn, String letter, boolean hidden) {
btn.setMessage(partToggleLabel(letter, hidden));
ClosetPreferences.save();
}
private static Component partToggleLabel(String letter, boolean hidden) {
return hidden
? Component.literal(letter).withStyle(ChatFormatting.STRIKETHROUGH, ChatFormatting.RED)
: Component.literal(letter);
}
/** 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) {
@ -297,15 +384,54 @@ public class ClosetScreen extends Screen {
private void selectCategory(String cat) {
selectedCategory = cat;
selectedSubFilter = null; // reset sub-filter on top-level tab change
cosmeticScroll = 0;
lastKnownSkinCount = -1; // force the next skin-tab tick to re-evaluate the grid
if (shareButton != null) shareButton.visible = SHARED_TAB.equals(cat);
rebuildCosmeticRow();
}
private void setBoneGroupSubFilter(@Nullable String sub) {
selectedSubFilter = sub;
rebuildCosmeticRow();
}
/** Sub-category pill row for the currently selected bone-group tab. Reuses the
* shared-tab filter bar layout constants so the row lines up visually. */
private void buildBoneGroupFilterBar(String[] subs) {
int fx = COSMETIC_ROW_LEFT;
// "All" first — null subFilter means show every sub-category in the group.
Button allBtn = Button.builder(
Component.literal("All"),
b -> setBoneGroupSubFilter(null)
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
allBtn.active = selectedSubFilter != null;
addRenderableWidget(allBtn);
filterPillButtons.add(allBtn);
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
for (String sub : subs) {
final String key = sub;
Button pill = Button.builder(
Component.literal(capitalize(sub)),
b -> setBoneGroupSubFilter(key)
).bounds(fx, SHARED_FILTER_Y, SHARED_FILTER_BTN_W, SHARED_FILTER_H).build();
pill.active = !sub.equals(selectedSubFilter);
addRenderableWidget(pill);
filterPillButtons.add(pill);
fx += SHARED_FILTER_BTN_W + SHARED_FILTER_BTN_GAP;
}
}
private static String capitalize(String s) {
return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
private void rebuildCosmeticRow() {
for (Button btn : cosmeticButtons) removeWidget(btn);
cosmeticButtons.clear();
for (Button btn : filterPillButtons) removeWidget(btn);
filterPillButtons.clear();
sharedButtonHashes.clear();
infoButtonHashes.clear();
@ -319,13 +445,30 @@ public class ClosetScreen extends Screen {
return;
}
// Bone-group tab? Show sub-filter pills and compute the allowed category set.
// Standalone tabs (particle) use a single-category set matching the tab name.
String[] groupSubs = BONE_GROUPS.get(selectedCategory);
if (groupSubs != null) {
buildBoneGroupFilterBar(groupSubs);
}
java.util.Set<String> allowed = new java.util.HashSet<>();
if (groupSubs != null) {
if (selectedSubFilter != null) {
allowed.add(selectedSubFilter);
} else {
for (String s : groupSubs) allowed.add(s);
}
} else {
allowed.add(selectedCategory);
}
int x = COSMETIC_ROW_LEFT - cosmeticScroll;
int y = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Authored cosmetics for this category.
// Authored cosmetics whose category falls in the allowed set.
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue;
if (!allowed.contains(def.category())) continue;
Identifier id = entry.getKey();
// No text label — the mini-player preview + green-border-when-equipped already
@ -355,7 +498,10 @@ public class ClosetScreen extends Screen {
: CosmeticLibrary.EMPTY;
for (CosmeticLibraryEntry libEntry : lib.entries()) {
if (!BoneExtraction.categoriesFor(libEntry.bones()).contains(selectedCategory)) continue;
java.util.Set<String> entryCats = BoneExtraction.categoriesFor(libEntry.bones());
boolean overlap = false;
for (String c : entryCats) { if (allowed.contains(c)) { overlap = true; break; } }
if (!overlap) continue;
// Pull from server if the blob hasn't materialized locally yet.
ClientSharedCosmeticCache.requestIfMissing(libEntry.hash());
@ -597,7 +743,7 @@ public class ClosetScreen extends Screen {
* 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}).
* {@code CosmeticShareNetwork.onAssignShared}).
*/
private void equipShared(CosmeticLibraryEntry entry) {
String category = BoneExtraction.primaryCategory(entry.bones());
@ -1090,6 +1236,15 @@ public class ClosetScreen extends Screen {
LocalPlayer player = mc.player;
if (player == null) return;
// Server-synced config may have flipped mid-session (op edited the TOML). Rebuild
// the entire screen so tabs/toggles appear or disappear to match.
boolean sharedNow = DecoFashionConfig.SHARED_LIBRARY_ENABLED.get();
boolean hideNow = DecoFashionConfig.BODY_HIDE_ENABLED.get();
if (sharedNow != lastSharedEnabled || hideNow != lastBodyHideEnabled) {
this.rebuildWidgets();
return;
}
// When on the skin tab, rebuild the grid when the cached-skin count changes — this
// covers uploads (which add a new entry asynchronously after the server echo arrives)
// and any new skin received from another player.
@ -1375,12 +1530,25 @@ public class ClosetScreen extends Screen {
Map<String, com.razz.dfashion.cosmetic.CosmeticRef> liveEquipped =
player.getData(CosmeticAttachments.EQUIPPED.get());
// Must match the allowed-set logic in rebuildCosmeticRow so button indices line up.
String[] groupSubs = BONE_GROUPS.get(selectedCategory);
java.util.Set<String> allowedCats = new java.util.HashSet<>();
if (groupSubs != null) {
if (selectedSubFilter != null) {
allowedCats.add(selectedSubFilter);
} else {
for (String s : groupSubs) allowedCats.add(s);
}
} else {
allowedCats.add(selectedCategory);
}
// 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;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
CosmeticDefinition def = entry.getValue();
if (!selectedCategory.equals(def.category())) continue;
if (!allowedCats.contains(def.category())) continue;
if (idx >= cosmeticButtons.size()) break;
Button btn = cosmeticButtons.get(idx);
@ -1514,7 +1682,7 @@ public class ClosetScreen extends Screen {
/**
* Same 3D-mini-player preview pattern as cosmetics, but per-box we swap the render state's
* {@code skin} to a specific cached texture so each box shows a different skin variant.
* {@link SkinInfoOverride} runs at tick-level on {@link PlayerInfo}, not on these
* {@link SkinInfoOverride} runs at tick-level on {@link net.minecraft.client.multiplayer.PlayerInfo}, not on these
* preview states, so our per-box swap here isn't clobbered.
*/
private void renderSkinPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {

@ -1,9 +1,11 @@
package com.razz.dfashion.cosmetic;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.BoolArgumentType;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.DecoFashionConfig;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
@ -35,9 +37,52 @@ public final class CosmeticCommands {
.executes(CosmeticCommands::unequip)))
.then(Commands.literal("list")
.executes(CosmeticCommands::list))
.then(Commands.literal("config")
.requires(s -> {
ServerPlayer p = s.getPlayer();
// Console source has no player — allow. Player source must be op.
return p == null || p.permissions().hasPermission(
net.minecraft.server.permissions.Permissions.COMMANDS_GAMEMASTER);
})
.then(Commands.literal("get")
.executes(CosmeticCommands::configGet))
.then(Commands.literal("sharedLibrary")
.then(Commands.argument("value", BoolArgumentType.bool())
.executes(ctx -> setConfig(ctx, "sharedLibrary",
BoolArgumentType.getBool(ctx, "value")))))
.then(Commands.literal("bodyHide")
.then(Commands.argument("value", BoolArgumentType.bool())
.executes(ctx -> setConfig(ctx, "bodyHide",
BoolArgumentType.getBool(ctx, "value"))))))
);
}
private static int configGet(CommandContext<CommandSourceStack> ctx) {
ctx.getSource().sendSuccess(() -> Component.literal(
"sharedLibrary = " + DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()
+ ", bodyHide = " + DecoFashionConfig.BODY_HIDE_ENABLED.get()
), false);
return 1;
}
private static int setConfig(CommandContext<CommandSourceStack> ctx, String key, boolean value) {
switch (key) {
case "sharedLibrary" -> DecoFashionConfig.SHARED_LIBRARY_ENABLED.set(value);
case "bodyHide" -> DecoFashionConfig.BODY_HIDE_ENABLED.set(value);
default -> {
ctx.getSource().sendFailure(Component.literal("Unknown config key: " + key));
return 0;
}
}
DecoFashionConfig.SPEC.save();
ctx.getSource().sendSuccess(
() -> Component.literal("Set " + key + " = " + value
+ " (change applied immediately)"),
true
);
return 1;
}
private static int equip(CommandContext<CommandSourceStack> ctx) {
ServerPlayer player = ctx.getSource().getPlayer();
if (player == null) {

@ -18,9 +18,20 @@ import java.util.List;
*/
public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
/** Per-player cap. Cosmetics have multiple slots and benefit from more variants than skins. */
/** Fallback cap, used when the server config hasn't been loaded yet. The authoritative
* per-player cap is {@link com.razz.dfashion.DecoFashionConfig#COSMETICS_PER_PLAYER}. */
public static final int MAX_ENTRIES = 15;
/** Returns the server-configured per-player cap, falling back to {@link #MAX_ENTRIES}
* if the config isn't loaded yet (which happens during early client setup). */
public static int maxEntries() {
try {
return com.razz.dfashion.DecoFashionConfig.COSMETICS_PER_PLAYER.get();
} catch (IllegalStateException e) {
return MAX_ENTRIES;
}
}
public static final CosmeticLibrary EMPTY = new CosmeticLibrary(List.of());
public static final Codec<CosmeticLibrary> CODEC =
@ -39,7 +50,7 @@ public record CosmeticLibrary(List<CosmeticLibraryEntry> entries) {
}
public boolean isFull() {
return entries.size() >= MAX_ENTRIES;
return entries.size() >= maxEntries();
}
public CosmeticLibrary add(CosmeticLibraryEntry entry) {

@ -102,6 +102,27 @@ public final class CosmeticShareNetwork {
MinecraftServer server = player.level().getServer();
if (server == null) return;
// Shared library disabled by server config: unequip any currently-equipped Shared
// refs so the player doesn't see a broken cosmetic. Keep the library attachment
// itself (preserves the player's personal collection if the op re-enables the
// feature later).
if (!com.razz.dfashion.DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
Map<String, CosmeticRef> equipped = player.getData(CosmeticAttachments.EQUIPPED.get());
Map<String, CosmeticRef> withoutShared = null;
for (Map.Entry<String, CosmeticRef> e : equipped.entrySet()) {
if (e.getValue() instanceof CosmeticRef.Shared) {
if (withoutShared == null) withoutShared = new HashMap<>(equipped);
withoutShared.remove(e.getKey());
}
}
if (withoutShared != null) {
player.setData(CosmeticAttachments.EQUIPPED.get(), withoutShared);
DecoFashion.LOGGER.info("Unequipped shared cosmetics for {} (feature disabled)",
player.getUUID());
}
return;
}
CosmeticLibrary lib = player.getData(CosmeticAttachments.SHARED_LIBRARY.get());
if (lib.entries().isEmpty()) return;
@ -192,6 +213,11 @@ public final class CosmeticShareNetwork {
private static void onUploadChunk(UploadCosmeticChunk msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return;
if (!com.razz.dfashion.DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
DecoFashion.LOGGER.debug("Rejecting upload from {}: shared library disabled by server config",
player.getUUID());
return;
}
MinecraftServer server = player.level().getServer();
if (server == null) return;
@ -278,6 +304,11 @@ public final class CosmeticShareNetwork {
private static void onAssignShared(AssignSharedCosmetic msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return;
if (!com.razz.dfashion.DecoFashionConfig.SHARED_LIBRARY_ENABLED.get()) {
DecoFashion.LOGGER.debug("Rejecting assign-shared from {}: shared library disabled",
player.getUUID());
return;
}
String slot = msg.slot();
if (slot == null || slot.isEmpty() || slot.length() > 64) {
DecoFashion.LOGGER.warn("AssignSharedCosmetic from {}: bad slot", player.getUUID());

@ -390,7 +390,7 @@ public final class SharedCosmeticCache {
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);
playerId, CosmeticLibrary.maxEntries(), hash);
return null;
}

@ -0,0 +1,99 @@
package com.razz.dfashion.mixin;
import com.razz.dfashion.DecoFashionConfig;
import com.razz.dfashion.client.ClosetPreferences;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.HumanoidModel;
import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.model.player.PlayerModel;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
/**
* Appends local-player body-part hiding to the end of {@code PlayerModel.setupAnim}.
*
* <p>Using {@code @Inject} at {@code @At("TAIL")} with no cancellation and a
* mod-namespaced method name is the most compatible mixin form we can use:
* <ul>
* <li>Other mods that inject at HEAD/MIDDLE still see their invocations
* unchanged; we only run after {@code super.setupAnim}.</li>
* <li>Other mods that inject at TAIL run alongside ours, ordered by Mixin
* priority. Since we only <em>set</em> visibility to false (never true),
* we're purely subtractive we can't override another mod's decision to
* hide a part.</li>
* <li>The injected method name is {@code decofashion$...} so it can't
* collide with other mods' injections on the same target.</li>
* </ul>
*
* <p>Only applies when the render state's entity id matches the local player,
* so remote players are unaffected even though they share this PlayerModel
* instance.
*
* <p>We extend {@link HumanoidModel} so inherited fields ({@code head},
* {@code body}, {@code leftArm}, etc.) resolve without requiring {@code @Shadow}
* on each. The constructor body is never executed Mixin discards it but
* it must exist for the Java compiler.
*/
@Mixin(PlayerModel.class)
public abstract class PlayerModelMixin extends HumanoidModel<AvatarRenderState> {
private PlayerModelMixin(ModelPart root) {
super(root);
throw new AssertionError();
}
@Shadow public ModelPart jacket;
@Shadow public ModelPart leftSleeve;
@Shadow public ModelPart rightSleeve;
@Shadow public ModelPart leftPants;
@Shadow public ModelPart rightPants;
@Inject(
method = "setupAnim(Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;)V",
at = @At("TAIL")
)
private void decofashion$applyHideToggles(AvatarRenderState state, CallbackInfo ci) {
Minecraft mc = Minecraft.getInstance();
boolean isLocal = mc.player != null && state.id == mc.player.getId();
boolean featureOn = DecoFashionConfig.BODY_HIDE_ENABLED.get();
// head.visible MUST be set every call because:
// - vanilla setupAnim doesn't touch it, and
// - the PlayerModel instance is shared across every player of the same skin type.
// If we only set it "sometimes", a prior hide leaks into every subsequent render
// (local OR remote). Always resolve to the correct value for this specific state.
this.head.visible = !(isLocal && featureOn && ClosetPreferences.hideHead);
// Everything below is a pure-subtractive override scoped to the local player
// when the feature is enabled. vanilla setupAnim resets body/arms/legs and the
// overlay bones per-frame based on the state, so we don't need to restore those
// for the "off" case — they correct themselves on the next render.
if (!isLocal || !featureOn) return;
if (ClosetPreferences.hideHead) {
this.hat.visible = false;
}
if (ClosetPreferences.hideBody) {
this.body.visible = false;
this.jacket.visible = false;
}
if (ClosetPreferences.hideArms) {
this.rightArm.visible = false;
this.leftArm.visible = false;
this.rightSleeve.visible = false;
this.leftSleeve.visible = false;
}
if (ClosetPreferences.hideLegs) {
this.rightLeg.visible = false;
this.leftLeg.visible = false;
this.rightPants.visible = false;
this.leftPants.visible = false;
}
}
}

@ -218,7 +218,7 @@ public final class SkinCache {
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);
playerId, SkinLibrary.maxEntries(), hash);
return null;
}

@ -20,7 +20,18 @@ import java.util.List;
*/
public record SkinLibrary(List<SkinLibraryEntry> entries) {
/** Fallback cap. Authoritative per-player cap is
* {@link com.razz.dfashion.DecoFashionConfig#SKINS_PER_PLAYER}. */
public static final int MAX_ENTRIES = 5;
public static int maxEntries() {
try {
return com.razz.dfashion.DecoFashionConfig.SKINS_PER_PLAYER.get();
} catch (IllegalStateException e) {
return MAX_ENTRIES;
}
}
public static final SkinLibrary EMPTY = new SkinLibrary(List.of());
public static final Codec<SkinLibrary> CODEC =
@ -39,7 +50,7 @@ public record SkinLibrary(List<SkinLibraryEntry> entries) {
}
public boolean isFull() {
return entries.size() >= MAX_ENTRIES;
return entries.size() >= maxEntries();
}
public SkinLibrary add(SkinLibraryEntry entry) {

@ -0,0 +1,12 @@
{
"required": true,
"minVersion": "0.8",
"package": "com.razz.dfashion.mixin",
"compatibilityLevel": "JAVA_21",
"client": [
"PlayerModelMixin"
],
"injectors": {
"defaultRequire": 1
}
}

@ -44,8 +44,8 @@ Example mod description.
'''
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
#[[mixins]]
#config="${mod_id}.mixins.json"
[[mixins]]
config="${mod_id}.mixins.json"
# The [[accessTransformers]] block allows you to declare where your AT file is.
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg

Loading…
Cancel
Save