Skin system and server sync and configuration added

main
MomokoKoigakubo 1 month ago
parent 42994b15a3
commit e8dfa02160

@ -5,6 +5,7 @@ import org.slf4j.Logger;
import com.mojang.logging.LogUtils; import com.mojang.logging.LogUtils;
import com.razz.dfashion.block.ClosetRegistry; import com.razz.dfashion.block.ClosetRegistry;
import com.razz.dfashion.cosmetic.CosmeticAttachments; import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.skin.SkinAttachments;
import net.neoforged.bus.api.IEventBus; import net.neoforged.bus.api.IEventBus;
import net.neoforged.fml.ModContainer; import net.neoforged.fml.ModContainer;
@ -19,6 +20,7 @@ public class DecoFashion {
public DecoFashion(IEventBus modEventBus, ModContainer modContainer) { public DecoFashion(IEventBus modEventBus, ModContainer modContainer) {
modEventBus.addListener(this::commonSetup); modEventBus.addListener(this::commonSetup);
CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus); CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus);
SkinAttachments.ATTACHMENT_TYPES.register(modEventBus);
ClosetRegistry.register(modEventBus); ClosetRegistry.register(modEventBus);
} }

@ -7,16 +7,22 @@ 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.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.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.mojang.blaze3d.platform.NativeImage;
import net.minecraft.client.Minecraft;
import net.minecraft.client.model.geom.ModelPart; import net.minecraft.client.model.geom.ModelPart;
import net.minecraft.client.player.AbstractClientPlayer; import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.entity.player.AvatarRenderer; import net.minecraft.client.renderer.entity.player.AvatarRenderer;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureManager;
import net.minecraft.resources.Identifier; import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.Resource; import net.minecraft.server.packs.resources.Resource;
import net.minecraft.server.packs.resources.ResourceManager; import net.minecraft.server.packs.resources.ResourceManager;
@ -27,12 +33,16 @@ import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.fml.common.Mod; import net.neoforged.fml.common.Mod;
import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent; import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent;
import net.neoforged.fml.loading.FMLPaths;
import net.neoforged.neoforge.client.event.AddClientReloadListenersEvent; 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.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@ -68,17 +78,69 @@ public class DecoFashionClient {
event.registerBlockEntityRenderer(ClosetRegistry.CLOSET_BE.get(), ClosetRenderer::new); event.registerBlockEntityRenderer(ClosetRegistry.CLOSET_BE.get(), ClosetRenderer::new);
} }
static void load(ResourceManager rm) { public static void load(ResourceManager rm) {
Map<Identifier, CosmeticDefinition> catalog = CosmeticCatalog.loadAll(rm); Map<Identifier, CosmeticDefinition> catalog = CosmeticCatalog.loadAll(rm);
Map<Identifier, CosmeticCache.Baked> baked = new HashMap<>(); Map<Identifier, CosmeticCache.Baked> baked = new HashMap<>();
for (Map.Entry<Identifier, CosmeticDefinition> entry : catalog.entrySet()) { for (Map.Entry<Identifier, CosmeticDefinition> entry : catalog.entrySet()) {
loadOne(rm, entry.getKey(), entry.getValue(), baked); loadOne(rm, entry.getKey(), entry.getValue(), baked);
} }
loadUserCosmetics(catalog, baked);
CosmeticCache.cosmetics = baked; CosmeticCache.cosmetics = baked;
CosmeticCache.catalog = catalog; CosmeticCache.catalog = catalog;
DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size()); DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size());
loadCloset(rm); loadCloset(rm);
// 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.
ClientSkinCache.scanDisk();
}
/**
* Scans {@code <gameDir>/decofashion/cosmetics/<category>/<folder>/} and folds user-dropped
* bbmodels + textures into the already-built catalog/baked maps. Textures are registered
* into the client's {@link TextureManager} under synthetic {@code decofashion_user:...} ids.
*/
private static void loadUserCosmetics(
Map<Identifier, CosmeticDefinition> catalog,
Map<Identifier, CosmeticCache.Baked> baked
) {
List<UserCosmeticLoader.Entry> entries = UserCosmeticLoader.scan(FMLPaths.GAMEDIR.get());
if (entries.isEmpty()) return;
TextureManager tm = Minecraft.getInstance().getTextureManager();
Map<java.nio.file.Path, Bbmodel> modelCache = new HashMap<>();
for (UserCosmeticLoader.Entry entry : entries) {
Bbmodel model = modelCache.get(entry.modelFile());
if (model == null) {
try (Reader reader = Files.newBufferedReader(entry.modelFile())) {
model = BbModelParser.parse(reader);
modelCache.put(entry.modelFile(), model);
} catch (Exception ex) {
DecoFashion.LOGGER.error("User cosmetic {}: failed to parse {}",
entry.id(), entry.modelFile(), ex);
continue;
}
}
try (InputStream in = Files.newInputStream(entry.textureFile())) {
NativeImage image = NativeImage.read(in);
Identifier texId = entry.def().texture();
tm.register(texId, new DynamicTexture(() -> "decofashion user " + texId, image));
} catch (IOException ex) {
DecoFashion.LOGGER.error("User cosmetic {}: failed to load texture {}",
entry.id(), entry.textureFile(), ex);
continue;
}
Map<String, ModelPart> parts = BbmodelBaker.bake(
model, model.resolutionWidth(), model.resolutionHeight(), true);
baked.put(entry.id(), new CosmeticCache.Baked(parts, entry.def().texture()));
catalog.put(entry.id(), entry.def());
DecoFashion.LOGGER.info("Loaded user cosmetic {} [{}]",
entry.id(), entry.def().displayName());
}
} }
/** /**

@ -0,0 +1,65 @@
package com.razz.dfashion.client;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.context.CommandContext;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.DecoFashionClient;
import com.razz.dfashion.skin.SkinModel;
import net.minecraft.client.Minecraft;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent;
import java.nio.file.Path;
import java.nio.file.Paths;
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
public final class ClientCommands {
@SubscribeEvent
static void onRegisterClientCommands(RegisterClientCommandsEvent event) {
CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();
dispatcher.register(
Commands.literal("decofashion_client")
.then(Commands.literal("reload").executes(ClientCommands::reload))
.then(Commands.literal("upload_skin")
.then(Commands.argument("path", StringArgumentType.greedyString())
.executes(ctx -> uploadSkin(ctx, SkinModel.WIDE))
)
)
);
}
private static int reload(CommandContext<CommandSourceStack> ctx) {
Minecraft mc = Minecraft.getInstance();
DecoFashionClient.load(mc.getResourceManager());
ctx.getSource().sendSuccess(
() -> Component.literal("DecoFashion: cosmetics reloaded"), false);
return 1;
}
private static int uploadSkin(CommandContext<CommandSourceStack> ctx, SkinModel model) {
String raw = StringArgumentType.getString(ctx, "path").trim();
if ((raw.startsWith("\"") && raw.endsWith("\"")) || (raw.startsWith("'") && raw.endsWith("'"))) {
raw = raw.substring(1, raw.length() - 1);
}
Path p;
try {
p = Paths.get(raw);
} catch (Exception ex) {
ctx.getSource().sendFailure(Component.literal("Invalid path: " + raw));
return 0;
}
String msg = ClientSkinCache.uploadFromFile(p, model);
ctx.getSource().sendSuccess(() -> Component.literal(msg + " (" + model + ")"), false);
return 1;
}
private ClientCommands() {}
}

@ -0,0 +1,269 @@
package com.razz.dfashion.client;
import com.mojang.blaze3d.platform.NativeImage;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SkinCache;
import com.razz.dfashion.skin.SkinModel;
import com.razz.dfashion.skin.packet.UploadSkinChunk;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.client.renderer.texture.TextureManager;
import net.minecraft.network.protocol.common.ServerboundCustomPayloadPacket;
import net.minecraft.resources.Identifier;
import net.neoforged.fml.loading.FMLPaths;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Client-side skin store. Writes received PNGs to {@code <gameDir>/decofashion/skins_cache/<hash>.png},
* registers them as {@link DynamicTexture}s under synthetic {@code decofashion:skins/<hash>} identifiers,
* and tracks in-flight downloads keyed by hash.
*/
public final class ClientSkinCache {
private static final String SUBDIR = "decofashion/skins_cache";
private static final Map<String, Identifier> REGISTERED = new ConcurrentHashMap<>();
private static final Map<String, Download> IN_FLIGHT = new ConcurrentHashMap<>();
private static final class Download {
int expectedTotal;
int nextIndex;
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
}
private ClientSkinCache() {}
private static Path root() {
return FMLPaths.GAMEDIR.get().resolve(SUBDIR);
}
private static Path fileFor(String hash) {
return root().resolve(hash + ".png");
}
public static Identifier textureIdFor(String hash) {
return Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skins/" + hash);
}
/** Already have a {@link DynamicTexture} registered for this hash? */
public static boolean hasRegistered(String hash) {
return REGISTERED.containsKey(hash);
}
public static boolean hasOnDisk(String hash) {
return Files.isRegularFile(fileFor(hash));
}
/** Load from disk + register if we have the PNG cached but haven't built a texture yet. */
public static Identifier ensureLoadedFromDisk(String hash) {
Identifier existing = REGISTERED.get(hash);
if (existing != null) return existing;
if (!hasOnDisk(hash)) return null;
try (InputStream in = Files.newInputStream(fileFor(hash))) {
return register(hash, in);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin cache: failed reading {}", hash, ex);
return null;
}
}
/** Begin a chunked download for this hash. Later {@link #onChunk} calls reassemble. */
public static boolean isAwaiting(String hash) {
return IN_FLIGHT.containsKey(hash);
}
public static void markRequested(String hash) {
IN_FLIGHT.putIfAbsent(hash, new Download());
}
/**
* Consume one inbound {@code SkinChunk}. When the last chunk lands, writes to disk,
* registers a {@link DynamicTexture}, and returns its 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) {
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.buffer.reset();
} else if (d.expectedTotal != total || d.nextIndex != index) {
DecoFashion.LOGGER.warn("Skin download {}: out-of-order chunk {}/{} (expected {}/{})",
hash, index, total, d.nextIndex, d.expectedTotal);
IN_FLIGHT.remove(hash);
return null;
}
if (d.buffer.size() + data.length > SkinCache.MAX_BYTES) {
DecoFashion.LOGGER.warn("Skin download {}: exceeds size cap", hash);
IN_FLIGHT.remove(hash);
return null;
}
try {
d.buffer.write(data);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: write failed", hash, ex);
IN_FLIGHT.remove(hash);
return null;
}
d.nextIndex = index + 1;
if (index + 1 < total) return null;
IN_FLIGHT.remove(hash);
byte[] png = d.buffer.toByteArray();
try {
Files.createDirectories(root());
Files.write(fileFor(hash), png);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: disk write failed", hash, ex);
}
try (InputStream in = new java.io.ByteArrayInputStream(png)) {
return register(hash, in);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin download {}: image decode failed", hash, ex);
return null;
}
}
private static Identifier register(String hash, InputStream in) throws IOException {
NativeImage img = NativeImage.read(in);
TextureManager tm = Minecraft.getInstance().getTextureManager();
Identifier id = textureIdFor(hash);
tm.register(id, new DynamicTexture(() -> "decofashion skin " + hash, img));
REGISTERED.put(hash, id);
DecoFashion.LOGGER.info("Skin registered: hash={} id={}", hash, id);
return id;
}
public static Map<String, Identifier> snapshotRegistered() {
return new HashMap<>(REGISTERED);
}
/**
* Register every cached PNG under {@code <gameDir>/decofashion/skins_cache/}. Runs on
* client setup and wardrobe open so previously-uploaded skins (which persist as files)
* reappear in the grid without waiting for an attachment to name them.
* Filename must be a 64-char lowercase hex hash with {@code .png} suffix to be accepted.
*/
public static void scanDisk() {
Path dir = root();
if (!Files.isDirectory(dir)) return;
int loaded = 0;
try (java.nio.file.DirectoryStream<Path> stream = Files.newDirectoryStream(dir, "*.png")) {
for (Path png : stream) {
String name = png.getFileName().toString();
if (name.length() != 68) continue; // 64 hex + ".png"
String hash = name.substring(0, 64);
if (!hash.matches("[0-9a-f]+")) continue;
if (REGISTERED.containsKey(hash)) continue;
if (ensureLoadedFromDisk(hash) != null) loaded++;
}
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Skin cache scan failed: {}", ex.getMessage());
}
if (loaded > 0) DecoFashion.LOGGER.info("Skin cache: scanned {} skin(s) from disk", loaded);
}
/**
* Drop a cached skin from this client releases the texture, forgets the hashid map
* entry, and removes the on-disk PNG. The server's copy is untouched (other players
* may still be using it). Returns true if anything was removed.
*/
public static boolean delete(String hash) {
Identifier id = REGISTERED.remove(hash);
boolean changed = id != null;
if (id != null) {
try {
Minecraft.getInstance().getTextureManager().release(id);
} catch (Throwable t) {
DecoFashion.LOGGER.warn("Skin cache: texture release failed for {}: {}", hash, t.getMessage());
}
}
try {
if (Files.deleteIfExists(fileFor(hash))) changed = true;
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Skin cache: file delete failed for {}: {}", hash, ex.getMessage());
}
DecoFashion.LOGGER.info("Skin cache: delete({}) changed={}", hash, changed);
return changed;
}
/**
* Read a PNG from disk, validate, chunk it, and ship the chunks upstream via
* {@link UploadSkinChunk}. Also caches the PNG locally (same hash the server will compute)
* so the wardrobe grid can show it immediately without waiting for the server round-trip.
* Returns a short user-facing status string for display.
*/
public static String uploadFromFile(Path file, SkinModel model) {
if (!Files.isRegularFile(file)) return "Not a file: " + file;
byte[] png;
try {
png = Files.readAllBytes(file);
} catch (IOException ex) {
return "Read failed: " + ex.getMessage();
}
if (png.length > SkinCache.MAX_BYTES) {
return "Too large: " + png.length + " > " + SkinCache.MAX_BYTES;
}
if (png.length < 8
|| png[0] != (byte) 0x89 || png[1] != 'P' || png[2] != 'N' || png[3] != 'G') {
return "Not a PNG";
}
ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return "Not connected";
// Local-cache first so the grid picks it up without waiting for the server echo.
String localHash = null;
try {
localHash = sha256Hex(png);
Files.createDirectories(root());
if (!hasOnDisk(localHash)) Files.write(fileFor(localHash), png);
ensureLoadedFromDisk(localHash);
} catch (Exception ex) {
DecoFashion.LOGGER.warn("Local skin cache on upload failed: {}", ex.getMessage());
}
int chunk = SkinCache.MAX_CHUNK_BYTES;
int total = Math.max(1, (png.length + chunk - 1) / chunk);
for (int i = 0; i < total; i++) {
int off = i * chunk;
int len = Math.min(chunk, png.length - off);
byte[] slice = new byte[len];
System.arraycopy(png, off, slice, 0, len);
conn.send(new ServerboundCustomPayloadPacket(new UploadSkinChunk(i, total, model, slice)));
}
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 {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] out = md.digest(in);
StringBuilder sb = new StringBuilder(out.length * 2);
for (byte b : out) sb.append(String.format("%02x", b));
return sb.toString();
}
/** Shortcut: send any server-bound payload from the client. */
public static void sendToServer(net.minecraft.network.protocol.common.custom.CustomPacketPayload payload) {
ClientPacketListener conn = Minecraft.getInstance().getConnection();
if (conn == null) return;
conn.send(new ServerboundCustomPayloadPacket(payload));
}
}

@ -0,0 +1,166 @@
package com.razz.dfashion.client;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SkinAttachments;
import com.razz.dfashion.skin.SkinData;
import com.razz.dfashion.skin.SkinModel;
import com.razz.dfashion.skin.packet.RequestSkin;
import net.minecraft.client.Minecraft;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.client.multiplayer.ClientPacketListener;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.core.ClientAsset;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.entity.player.PlayerModelType;
import net.minecraft.world.entity.player.PlayerSkin;
import net.neoforged.api.distmarker.Dist;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import java.lang.reflect.Field;
import java.util.function.Supplier;
/**
* Wraps {@link PlayerInfo#getSkin()}'s internal {@code skinLookup} supplier so that
* {@code AbstractClientPlayer.getSkin()} which both the dispatcher uses to pick
* {@code PLAYER} vs {@code PLAYER_SLIM} and the renderer reads into
* {@code state.skin} returns our cached texture with the chosen
* {@link PlayerModelType}. This is what actually flips live arm geometry (3px vs 4px)
* and, as a side-effect, swaps the body texture too; no {@code RenderPlayerEvent} swap
* is needed.
*
* <p>Synced each client tick against every visible player's {@link SkinData}
* attachment. When a hash isn't cached locally yet, a {@link RequestSkin} is fired
* and the override is deferred to a future tick (keeping the vanilla skin in place
* in the meantime).
*/
@EventBusSubscriber(modid = DecoFashion.MODID, value = Dist.CLIENT)
public final class SkinInfoOverride {
private static final Field SKIN_LOOKUP_FIELD = findSkinLookupField();
private static Field findSkinLookupField() {
try {
Field f = PlayerInfo.class.getDeclaredField("skinLookup");
f.setAccessible(true);
return f;
} catch (NoSuchFieldException ex) {
DecoFashion.LOGGER.error("PlayerInfo.skinLookup not found — skin overrides disabled", ex);
return null;
}
}
@SubscribeEvent
static void onClientTickPre(ClientTickEvent.Pre event) { syncAll(); }
@SubscribeEvent
static void onClientTickPost(ClientTickEvent.Post event) { syncAll(); }
/** Also callable directly — e.g. on button clicks — to avoid the up-to-50ms tick latency. */
public static void syncAll() {
if (SKIN_LOOKUP_FIELD == null) return;
Minecraft mc = Minecraft.getInstance();
ClientLevel level = mc.level;
if (level == null) return;
ClientPacketListener conn = mc.getConnection();
if (conn == null) return;
for (Player p : level.players()) {
if (!(p instanceof AbstractClientPlayer acp)) continue;
PlayerInfo info = conn.getPlayerInfo(acp.getUUID());
if (info == null) continue;
syncOne(info, acp);
}
}
private static void syncOne(PlayerInfo info, AbstractClientPlayer player) {
// Force lazy init of skinLookup so we have a supplier to wrap.
info.getSkin();
Supplier<PlayerSkin> current = getLookup(info);
if (current == null) return;
SkinData data = player.getData(SkinAttachments.DATA.get());
if (data == null || data.isEmpty()) {
if (current instanceof OverrideLookup ol) setLookup(info, ol.original);
return;
}
Identifier tex = ClientSkinCache.ensureLoadedFromDisk(data.hash());
if (tex == null) {
// Bytes aren't local yet — request them and leave the vanilla skin in place
// rather than flashing a broken texture for one frame.
if (!ClientSkinCache.isAwaiting(data.hash())) {
ClientSkinCache.markRequested(data.hash());
ClientSkinCache.sendToServer(new RequestSkin(data.hash()));
}
if (current instanceof OverrideLookup ol) setLookup(info, ol.original);
return;
}
PlayerModelType desired = data.model() == SkinModel.SLIM
? PlayerModelType.SLIM : PlayerModelType.WIDE;
if (current instanceof OverrideLookup ol
&& ol.hash.equals(data.hash())
&& ol.model == desired) {
return; // already correct
}
Supplier<PlayerSkin> original = current instanceof OverrideLookup ol ? ol.original : current;
setLookup(info, new OverrideLookup(original, tex, data.hash(), desired));
}
@SuppressWarnings("unchecked")
private static Supplier<PlayerSkin> getLookup(PlayerInfo info) {
try {
return (Supplier<PlayerSkin>) SKIN_LOOKUP_FIELD.get(info);
} catch (IllegalAccessException ex) {
return null;
}
}
private static void setLookup(PlayerInfo info, Supplier<PlayerSkin> lookup) {
try {
SKIN_LOOKUP_FIELD.set(info, lookup);
} catch (IllegalAccessException ex) {
DecoFashion.LOGGER.warn("PlayerInfo.skinLookup set failed: {}", ex.getMessage());
}
}
/** Wraps the original vanilla lookup, overriding body texture + model type. */
private static final class OverrideLookup implements Supplier<PlayerSkin> {
final Supplier<PlayerSkin> original;
final Identifier texture;
final String hash;
final PlayerModelType model;
OverrideLookup(Supplier<PlayerSkin> original, Identifier texture,
String hash, PlayerModelType model) {
this.original = original;
this.texture = texture;
this.hash = hash;
this.model = model;
}
@Override
public PlayerSkin get() {
PlayerSkin base = original != null ? original.get() : null;
ClientAsset.Texture body =
new ClientAsset.DownloadedTexture(texture, "decofashion:skin/" + hash);
return PlayerSkin.insecure(
body,
base != null ? base.cape() : null,
base != null ? base.elytra() : null,
model
);
}
}
private SkinInfoOverride() {}
}

@ -1,10 +1,20 @@
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.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.cosmetic.CosmeticAttachments; import com.razz.dfashion.cosmetic.CosmeticAttachments;
import com.razz.dfashion.cosmetic.CosmeticDefinition; import com.razz.dfashion.cosmetic.CosmeticDefinition;
import com.razz.dfashion.skin.SkinAttachments;
import com.razz.dfashion.skin.SkinData;
import com.razz.dfashion.skin.SkinModel;
import com.razz.dfashion.skin.packet.AssignSkin;
import net.minecraft.core.ClientAsset;
import net.minecraft.world.entity.player.PlayerModelType;
import net.minecraft.world.entity.player.PlayerSkin;
import net.minecraft.client.Camera; import net.minecraft.client.Camera;
import net.minecraft.client.CameraType; import net.minecraft.client.CameraType;
@ -25,6 +35,10 @@ import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.neoforge.client.event.ClientTickEvent; import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.common.NeoForge;
import org.lwjgl.PointerBuffer;
import org.lwjgl.system.MemoryStack;
import org.lwjgl.util.tinyfd.TinyFileDialogs;
import org.jspecify.annotations.Nullable; import org.jspecify.annotations.Nullable;
import org.joml.Matrix4f; import org.joml.Matrix4f;
import org.joml.Quaternionf; import org.joml.Quaternionf;
@ -32,15 +46,18 @@ import org.joml.Vector3f;
import org.lwjgl.glfw.GLFW; import org.lwjgl.glfw.GLFW;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
public class ClosetScreen extends Screen { public class ClosetScreen extends Screen {
// Tab vocabulary — matches category memory. // 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.
private static final String[] CATEGORIES = { private static final String[] CATEGORIES = {
"hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle" "hat", "head", "torso", "arms", "legs", "wrist", "feet", "wings", "particle", "skin"
}; };
private static final String SKIN_TAB = "skin";
// 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;
@ -74,6 +91,18 @@ public class ClosetScreen extends Screen {
private final List<Button> cosmeticButtons = new ArrayList<>(); private final List<Button> cosmeticButtons = new ArrayList<>();
private boolean showPreviews = true; // toggled by the bottom-right button 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
// and the "Slim/Wide" toggle label. Seeded from the player's live SkinData on tab open.
private SkinModel pendingSkinModel = SkinModel.WIDE;
// Maps a skin-box button to its cached skin hash — needed so the preview renderer can
// look up which skin to show per box, and so the delete button knows what to remove.
private final Map<Button, String> skinButtonHashes = new HashMap<>();
// Last-known cached-skin count; when the cache grows (upload finishes + registers), the
// skin grid needs a rebuild. Checked each tick while the skin tab is active.
private int lastKnownSkinCount = -1;
// True once one-time setup (event-bus registration, initial displayYaw, etc.) has run. // True once one-time setup (event-bus registration, initial displayYaw, etc.) has run.
// Guards against init() re-runs triggered by setScreen(this) after equip commands. // Guards against init() re-runs triggered by setScreen(this) after equip commands.
private boolean oneTimeInitDone = false; private boolean oneTimeInitDone = false;
@ -112,6 +141,10 @@ public class ClosetScreen extends Screen {
NeoForge.EVENT_BUS.register(this); NeoForge.EVENT_BUS.register(this);
if (selectedCategory == null) selectedCategory = CATEGORIES[0]; if (selectedCategory == null) selectedCategory = CATEGORIES[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.
ClientSkinCache.scanDisk();
} }
buildTabs(); buildTabs();
@ -162,6 +195,7 @@ public class ClosetScreen extends Screen {
private void selectCategory(String cat) { private void selectCategory(String cat) {
selectedCategory = cat; selectedCategory = cat;
cosmeticScroll = 0; cosmeticScroll = 0;
lastKnownSkinCount = -1; // force the next skin-tab tick to re-evaluate the grid
rebuildCosmeticRow(); rebuildCosmeticRow();
} }
@ -170,6 +204,10 @@ public class ClosetScreen extends Screen {
cosmeticButtons.clear(); cosmeticButtons.clear();
if (selectedCategory == null) return; if (selectedCategory == null) return;
if (SKIN_TAB.equals(selectedCategory)) {
buildSkinTabRow();
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;
@ -198,6 +236,164 @@ public class ClosetScreen extends Screen {
} }
} }
// Skin tab layout — action buttons stack vertically on the left, grid runs right.
private static final int SKIN_ACTION_W = 90;
private static final int SKIN_ACTION_H = 20;
private static final int SKIN_ACTION_GAP = 4;
private static final int SKIN_GRID_LEFT_PAD = 8;
private static final int SKIN_GRID_START_X =
COSMETIC_ROW_LEFT + SKIN_ACTION_W + SKIN_GRID_LEFT_PAD;
/**
* Skin tab layout Upload / Slim-Wide toggle / Reset stacked vertically on the left,
* catalogue-shaped grid of cached skins to the right. Grid boxes are the same size as
* cosmetic boxes so the 3D preview path can be reused. Each box carries a tiny 'x'
* above it for per-skin delete.
*/
private void buildSkinTabRow() {
skinButtonHashes.clear();
// Seed toggle from whatever the player currently has on; otherwise remember last choice.
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) {
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && !current.isEmpty()) pendingSkinModel = current.model();
}
int gridY = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
// Center the 3-button action column vertically against the 36-tall grid row.
int columnHeight = SKIN_ACTION_H * 3 + SKIN_ACTION_GAP * 2; // 68
int actionYTop = gridY + (COSMETIC_SIZE - columnHeight) / 2; // gridY - 16
Button upload = Button.builder(
Component.literal("Upload Skin"),
b -> pickAndUpload(pendingSkinModel)
).bounds(COSMETIC_ROW_LEFT, actionYTop, SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(upload);
cosmeticButtons.add(upload);
Button toggle = Button.builder(
Component.literal(pendingSkinModel == SkinModel.SLIM ? "Slim" : "Wide"),
b -> flipSkinModel()
).bounds(COSMETIC_ROW_LEFT, actionYTop + SKIN_ACTION_H + SKIN_ACTION_GAP,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(toggle);
cosmeticButtons.add(toggle);
Button reset = Button.builder(
Component.literal("Reset"),
b -> {
LocalPlayer local = Minecraft.getInstance().player;
if (local != null) local.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
SkinInfoOverride.syncAll();
}
).bounds(COSMETIC_ROW_LEFT, actionYTop + (SKIN_ACTION_H + SKIN_ACTION_GAP) * 2,
SKIN_ACTION_W, SKIN_ACTION_H).build();
addRenderableWidget(reset);
cosmeticButtons.add(reset);
// Grid row starts past the action column. Boxes honor the Toggle button; action
// column above stays visible either way.
int boxX = SKIN_GRID_START_X - cosmeticScroll;
for (Map.Entry<String, Identifier> entry : ClientSkinCache.snapshotRegistered().entrySet()) {
final String hash = entry.getKey();
boolean onScreen = (boxX >= SKIN_GRID_START_X) && (boxX < this.width);
boolean show = showPreviews && onScreen;
Button pick = Button.builder(
Component.empty(),
b -> pickCachedSkin(hash)
).bounds(boxX, gridY, COSMETIC_SIZE, COSMETIC_SIZE).build();
pick.visible = show;
pick.active = show;
cosmeticButtons.add(pick);
addRenderableWidget(pick);
skinButtonHashes.put(pick, hash);
// Placed just ABOVE the pick box so it doesn't overlap — MC dispatches mouse
// events to widgets in insertion order, and `pick` (added first, full 36x36)
// would otherwise consume clicks before `del` could see them.
Button del = Button.builder(
Component.literal("x"),
b -> deleteCachedSkin(hash)
).bounds(boxX + COSMETIC_SIZE - 12, gridY - 12, 12, 12).build();
del.visible = show;
del.active = show;
cosmeticButtons.add(del);
addRenderableWidget(del);
boxX += COSMETIC_SIZE + COSMETIC_SPACING;
}
}
/**
* 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.
*/
private void pickCachedSkin(String hash) {
SkinData updated = new SkinData(hash, pendingSkinModel);
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) player.setData(SkinAttachments.DATA.get(), updated);
ClientSkinCache.sendToServer(new AssignSkin(hash, pendingSkinModel));
SkinInfoOverride.syncAll();
}
private void deleteCachedSkin(String 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;
if (player != null) {
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && hash.equals(current.hash())) {
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
ClientSkinCache.sendToServer(new AssignSkin("", SkinModel.WIDE));
SkinInfoOverride.syncAll();
}
}
lastKnownSkinCount = -1; // force the skin-count poll to re-pick up the change
rebuildCosmeticRow();
}
/**
* Flip the local toggle. If the player already has a skin equipped, tell the server to
* re-apply with the new model so it syncs to others AND set the local attachment
* immediately so the live player render picks up the new model this frame, instead of
* waiting on the server round-trip.
*/
private void flipSkinModel() {
pendingSkinModel = pendingSkinModel == SkinModel.SLIM ? SkinModel.WIDE : SkinModel.SLIM;
LocalPlayer player = Minecraft.getInstance().player;
if (player != null) {
SkinData current = player.getData(SkinAttachments.DATA.get());
if (current != null && !current.isEmpty()) {
SkinData updated = new SkinData(current.hash(), pendingSkinModel);
player.setData(SkinAttachments.DATA.get(), updated);
ClientSkinCache.sendToServer(new AssignSkin(current.hash(), pendingSkinModel));
SkinInfoOverride.syncAll();
}
}
rebuildCosmeticRow(); // refresh toggle label
}
/** Open the OS file picker for a PNG, upload what the user chose. */
private void pickAndUpload(SkinModel model) {
try (MemoryStack stack = MemoryStack.stackPush()) {
PointerBuffer filters = stack.mallocPointer(1);
filters.put(stack.UTF8("*.png"));
filters.flip();
String path = TinyFileDialogs.tinyfd_openFileDialog(
"Select skin PNG", "", filters, "PNG images", false);
if (path == null || path.isEmpty()) return;
ClientSkinCache.uploadFromFile(java.nio.file.Paths.get(path), model);
}
}
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;
@ -245,6 +441,17 @@ public class ClosetScreen extends Screen {
LocalPlayer player = mc.player; LocalPlayer player = mc.player;
if (player == null) return; if (player == null) 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.
if (SKIN_TAB.equals(selectedCategory)) {
int count = ClientSkinCache.snapshotRegistered().size();
if (count != lastKnownSkinCount) {
lastKnownSkinCount = count;
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;
@ -304,7 +511,10 @@ public class ClosetScreen extends Screen {
private boolean isInCosmeticRow(double mx, double my) { private boolean isInCosmeticRow(double mx, double my) {
int y0 = this.height - COSMETIC_ROW_BOTTOM_MARGIN; int y0 = this.height - COSMETIC_ROW_BOTTOM_MARGIN;
int y1 = y0 + COSMETIC_SIZE; int y1 = y0 + COSMETIC_SIZE;
return my >= y0 && my <= y1 && mx >= COSMETIC_ROW_LEFT; // On the skin tab the drag-scrollable area is to the right of the action column;
// presses on the stacked Upload/Slim/Reset buttons must not initiate a grid drag.
int left = SKIN_TAB.equals(selectedCategory) ? SKIN_GRID_START_X : COSMETIC_ROW_LEFT;
return my >= y0 && my <= y1 && mx >= left;
} }
private boolean isOverAnyWidget(double mx, double my) { private boolean isOverAnyWidget(double mx, double my) {
@ -390,14 +600,24 @@ public class ClosetScreen extends Screen {
} }
private void clampScroll() { private void clampScroll() {
int count = 0; int count;
if (selectedCategory != null) { int rowLeft;
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) { if (SKIN_TAB.equals(selectedCategory)) {
if (selectedCategory.equals(entry.getValue().category())) count++; // Skin grid: rows come from the client skin cache, and they start past the
// vertical action column, not at COSMETIC_ROW_LEFT.
count = ClientSkinCache.snapshotRegistered().size();
rowLeft = SKIN_GRID_START_X;
} else {
count = 0;
if (selectedCategory != null) {
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
if (selectedCategory.equals(entry.getValue().category())) count++;
}
} }
rowLeft = COSMETIC_ROW_LEFT;
} }
int totalWidth = count * (COSMETIC_SIZE + COSMETIC_SPACING); int totalWidth = count * (COSMETIC_SIZE + COSMETIC_SPACING);
int visibleWidth = Math.max(0, this.width - COSMETIC_ROW_LEFT - 10); int visibleWidth = Math.max(0, this.width - rowLeft - 10);
int maxScroll = Math.max(0, totalWidth - visibleWidth); int maxScroll = Math.max(0, totalWidth - visibleWidth);
cosmeticScroll = Math.max(0, Math.min(cosmeticScroll, maxScroll)); cosmeticScroll = Math.max(0, Math.min(cosmeticScroll, maxScroll));
} }
@ -442,6 +662,11 @@ public class ClosetScreen extends Screen {
LocalPlayer player = mc.player; LocalPlayer player = mc.player;
if (player == null || selectedCategory == null || cosmeticButtons.isEmpty()) return; if (player == null || selectedCategory == null || cosmeticButtons.isEmpty()) return;
if (SKIN_TAB.equals(selectedCategory)) {
renderSkinPreviews(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, Identifier> liveEquipped = player.getData(CosmeticAttachments.EQUIPPED.get());
@ -501,6 +726,68 @@ 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
* preview states, so our per-box swap here isn't clobbered.
*/
private void renderSkinPreviews(GuiGraphicsExtractor graphics, LocalPlayer player) {
if (skinButtonHashes.isEmpty()) return;
Minecraft mc = Minecraft.getInstance();
EntityRenderDispatcher dispatcher = mc.getEntityRenderDispatcher();
EntityRenderer<? super LivingEntity, ?> renderer = dispatcher.getRenderer(player);
SkinData liveSkin = player.getData(SkinAttachments.DATA.get());
for (Map.Entry<Button, String> entry : skinButtonHashes.entrySet()) {
Button btn = entry.getKey();
String hash = entry.getValue();
int fx0 = btn.getX();
int fy0 = btn.getY();
int fx1 = fx0 + btn.getWidth();
int fy1 = fy0 + btn.getHeight();
if (fx0 < SKIN_GRID_START_X || fx0 >= this.width) continue;
Identifier tex = ClientSkinCache.ensureLoadedFromDisk(hash);
if (tex == null) continue;
boolean isEquipped = liveSkin != null && hash.equals(liveSkin.hash());
graphics.outline(fx0, fy0, fx1 - fx0, fy1 - fy0,
isEquipped ? COSMETIC_BORDER_EQUIPPED_COLOR : COSMETIC_BORDER_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;
// Swap this state's skin to the cached texture; opt out of the per-player handler.
PlayerSkin existing = avatar.skin;
ClientAsset.Texture body = new ClientAsset.DownloadedTexture(tex, "decofashion:skin/" + hash);
PlayerModelType modelType = pendingSkinModel == SkinModel.SLIM
? PlayerModelType.SLIM : PlayerModelType.WIDE;
avatar.skin = existing == null
? PlayerSkin.insecure(body, null, null, modelType)
: PlayerSkin.insecure(body, existing.cape(), existing.elytra(), modelType);
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);
}
}
@Override @Override
public void onClose() { public void onClose() {
NeoForge.EVENT_BUS.unregister(this); NeoForge.EVENT_BUS.unregister(this);

@ -0,0 +1,158 @@
package com.razz.dfashion.cosmetic;
import com.razz.dfashion.DecoFashion;
import net.minecraft.resources.Identifier;
import java.io.IOException;
import java.nio.file.DirectoryStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
/**
* Scans {@code <gameDir>/decofashion/cosmetics/<category>/<folder>/} for user-authored
* cosmetics. One folder per cosmetic; category = parent dir name; variants = extra PNGs.
*/
public final class UserCosmeticLoader {
public static final String USER_NAMESPACE = "decofashion_user";
private static final String ROOT_DIR = "decofashion/cosmetics";
/** One scan result — a concrete (id, def, on-disk model file, on-disk texture file) tuple. */
public record Entry(
Identifier id,
CosmeticDefinition def,
Path modelFile,
Path textureFile
) {}
private UserCosmeticLoader() {}
public static List<Entry> scan(Path gameDir) {
List<Entry> out = new ArrayList<>();
Path root = gameDir.resolve(ROOT_DIR);
if (!Files.isDirectory(root)) {
try {
Files.createDirectories(root);
DecoFashion.LOGGER.info("Created user cosmetics folder at {}", root);
} catch (IOException ex) {
DecoFashion.LOGGER.warn("Couldn't create user cosmetics folder {}: {}",
root, ex.getMessage());
}
return out;
}
try (DirectoryStream<Path> categories = Files.newDirectoryStream(root)) {
for (Path categoryDir : categories) {
if (!Files.isDirectory(categoryDir)) continue;
String category = normalize(categoryDir.getFileName().toString());
if (category.isEmpty()) continue;
try (DirectoryStream<Path> folders = Files.newDirectoryStream(categoryDir)) {
for (Path folder : folders) {
if (!Files.isDirectory(folder)) continue;
scanOne(folder, category, out);
}
}
}
} catch (IOException ex) {
DecoFashion.LOGGER.error("Failed scanning user cosmetics under {}", root, ex);
}
return out;
}
private static void scanOne(Path cosmeticDir, String category, List<Entry> out) throws IOException {
String rawFolder = cosmeticDir.getFileName().toString();
String folderId = normalize(rawFolder);
if (folderId.isEmpty()) return;
Path modelFile = null;
List<Path> pngs = new ArrayList<>();
try (DirectoryStream<Path> files = Files.newDirectoryStream(cosmeticDir)) {
for (Path f : files) {
if (!Files.isRegularFile(f)) continue;
String name = f.getFileName().toString().toLowerCase(Locale.ROOT);
if (name.endsWith(".bbmodel")) {
if (modelFile == null) modelFile = f;
} else if (name.endsWith(".png")) {
pngs.add(f);
}
}
}
if (modelFile == null) {
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .bbmodel, skipping", category, rawFolder);
return;
}
if (pngs.isEmpty()) {
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .png, skipping", category, rawFolder);
return;
}
pngs.sort(Comparator.comparing(p -> p.getFileName().toString().toLowerCase(Locale.ROOT)));
// Single texture that matches the folder name → un-suffixed id/display.
boolean singleMatching = pngs.size() == 1
&& stem(pngs.get(0)).equalsIgnoreCase(rawFolder);
for (Path png : pngs) {
String textureId = normalize(stem(png));
if (textureId.isEmpty()) continue;
String idPath;
String displayName;
if (singleMatching) {
idPath = folderId;
displayName = titleCase(folderId);
} else {
idPath = folderId + "_" + textureId;
displayName = titleCase(folderId) + " " + titleCase(textureId);
}
Identifier id = Identifier.fromNamespaceAndPath(USER_NAMESPACE, idPath);
Identifier modelRef = Identifier.fromNamespaceAndPath(
USER_NAMESPACE, "cosmetic/" + idPath + ".bbmodel");
Identifier textureRef = Identifier.fromNamespaceAndPath(
USER_NAMESPACE, "textures/cosmetic/" + idPath + ".png");
CosmeticDefinition def = new CosmeticDefinition(displayName, category, modelRef, textureRef);
out.add(new Entry(id, def, modelFile, png));
}
}
private static String stem(Path file) {
String name = file.getFileName().toString();
int dot = name.lastIndexOf('.');
return dot < 0 ? name : name.substring(0, dot);
}
/** lowercase; spaces/hyphens → underscore; strip anything outside [a-z0-9_]. */
private static String normalize(String raw) {
StringBuilder sb = new StringBuilder(raw.length());
for (int i = 0; i < raw.length(); i++) {
char c = Character.toLowerCase(raw.charAt(i));
if (c == ' ' || c == '-') sb.append('_');
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') sb.append(c);
}
return sb.toString();
}
/** snake_case → Title Case. */
private static String titleCase(String id) {
if (id.isEmpty()) return id;
String[] parts = id.split("_");
StringBuilder sb = new StringBuilder();
for (String part : parts) {
if (part.isEmpty()) continue;
if (sb.length() > 0) sb.append(' ');
sb.append(Character.toUpperCase(part.charAt(0)));
if (part.length() > 1) sb.append(part.substring(1));
}
return sb.toString();
}
}

@ -0,0 +1,26 @@
package com.razz.dfashion.skin;
import com.razz.dfashion.DecoFashion;
import net.neoforged.neoforge.attachment.AttachmentType;
import net.neoforged.neoforge.registries.DeferredHolder;
import net.neoforged.neoforge.registries.DeferredRegister;
import net.neoforged.neoforge.registries.NeoForgeRegistries;
public final class SkinAttachments {
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinData>> DATA =
ATTACHMENT_TYPES.register(
"skin_data",
() -> AttachmentType.<SkinData>builder(() -> SkinData.EMPTY)
.serialize(SkinData.CODEC.fieldOf("skin"))
.sync(SkinData.STREAM_CODEC)
.copyOnDeath()
.build()
);
private SkinAttachments() {}
}

@ -0,0 +1,171 @@
package com.razz.dfashion.skin;
import com.razz.dfashion.DecoFashion;
import net.minecraft.server.MinecraftServer;
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.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Server-side skin store. Keeps PNG bytes on disk under {@code <serverDir>/decofashion/skins/<hash>.png},
* and tracks in-flight multi-chunk uploads per player.
*/
public final class SkinCache {
public static final int MAX_BYTES = 32 * 1024 * 1024; // 32 MB
public static final int MAX_CHUNK_BYTES = 512 * 1024; // 512 KB
private static final String SUBDIR = "decofashion/skins";
private static final Map<UUID, Assembly> IN_FLIGHT = new ConcurrentHashMap<>();
private SkinCache() {}
/** Per-player chunk assembly state. */
private static final class Assembly {
int expectedTotal;
int nextIndex;
SkinModel model;
ByteArrayOutputStream buffer;
}
private static Path root(MinecraftServer server) {
return server.getServerDirectory().resolve(SUBDIR);
}
public static Path fileFor(MinecraftServer server, String hash) {
return root(server).resolve(hash + ".png");
}
public static boolean has(MinecraftServer server, String hash) {
return Files.isRegularFile(fileFor(server, hash));
}
public static byte[] read(MinecraftServer server, String hash) throws IOException {
return Files.readAllBytes(fileFor(server, hash));
}
/**
* Accept one chunk from a player. Returns the finalized {@link SkinData} with the
* computed hash once the last chunk lands, or {@code null} if more chunks are expected
* (or the upload was rejected check logs).
*/
public static SkinData acceptChunk(
MinecraftServer server, UUID playerId,
int index, int total, SkinModel model, byte[] data
) {
if (total <= 0 || index < 0 || index >= total) {
DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, index, total);
IN_FLIGHT.remove(playerId);
return null;
}
if (data.length > MAX_CHUNK_BYTES) {
DecoFashion.LOGGER.warn("Skin upload from {}: chunk too large ({} > {})",
playerId, data.length, MAX_CHUNK_BYTES);
IN_FLIGHT.remove(playerId);
return null;
}
Assembly asm;
if (index == 0) {
asm = new Assembly();
asm.expectedTotal = total;
asm.nextIndex = 0;
asm.model = model;
asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_BYTES));
IN_FLIGHT.put(playerId, asm);
} else {
asm = IN_FLIGHT.get(playerId);
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index) {
DecoFashion.LOGGER.warn("Skin upload from {}: out-of-order chunk (got {}/{}, expected {}/{})",
playerId, index, total,
asm == null ? -1 : asm.nextIndex,
asm == null ? -1 : asm.expectedTotal);
IN_FLIGHT.remove(playerId);
return null;
}
}
try {
if (asm.buffer.size() + data.length > MAX_BYTES) {
DecoFashion.LOGGER.warn("Skin upload from {}: exceeds size cap ({})", playerId, MAX_BYTES);
IN_FLIGHT.remove(playerId);
return null;
}
asm.buffer.write(data);
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin 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);
byte[] png = asm.buffer.toByteArray();
String hash;
try {
hash = sha256Hex(png);
} catch (NoSuchAlgorithmException ex) {
DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
return null;
}
try {
Files.createDirectories(root(server));
Path target = fileFor(server, hash);
if (!Files.isRegularFile(target)) {
Files.write(target, png);
}
} catch (IOException ex) {
DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex);
return null;
}
DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} bytes={} model={}",
playerId, hash, png.length, asm.model);
return new SkinData(hash, asm.model);
}
public static void cancelInFlight(UUID playerId) {
IN_FLIGHT.remove(playerId);
}
/** Recognized hashes from disk — useful for validation / 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, "*.png")) {
for (Path p : stream) {
String name = p.getFileName().toString();
String hash = name.substring(0, name.length() - 4);
try {
out.put(hash, Files.size(p));
} catch (IOException ignored) {}
}
} catch (IOException ex) {
DecoFashion.LOGGER.warn("listCached failed: {}", ex.getMessage());
}
return out;
}
private static String sha256Hex(byte[] in) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] out = md.digest(in);
StringBuilder sb = new StringBuilder(out.length * 2);
for (byte b : out) sb.append(String.format("%02x", b));
return sb.toString();
}
}

@ -0,0 +1,54 @@
package com.razz.dfashion.skin;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.context.CommandContext;
import com.razz.dfashion.DecoFashion;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
@EventBusSubscriber(modid = DecoFashion.MODID)
public final class SkinCommands {
@SubscribeEvent
static void onRegisterCommands(RegisterCommandsEvent event) {
CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();
dispatcher.register(
Commands.literal("decofashion_skin")
.then(Commands.literal("reset").executes(SkinCommands::reset))
.then(Commands.literal("show").executes(SkinCommands::show))
);
}
private static int reset(CommandContext<CommandSourceStack> ctx) {
ServerPlayer player = ctx.getSource().getPlayer();
if (player == null) {
ctx.getSource().sendFailure(Component.literal("Must be a player"));
return 0;
}
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
ctx.getSource().sendSuccess(
() -> Component.literal("Skin reset to vanilla"), false);
return 1;
}
private static int show(CommandContext<CommandSourceStack> ctx) {
ServerPlayer player = ctx.getSource().getPlayer();
if (player == null) {
ctx.getSource().sendFailure(Component.literal("Must be a player"));
return 0;
}
SkinData d = player.getData(SkinAttachments.DATA.get());
ctx.getSource().sendSuccess(
() -> Component.literal("Skin: " + (d.isEmpty() ? "<vanilla>" : d.hash()) + " (" + d.model() + ")"),
false);
return 1;
}
private SkinCommands() {}
}

@ -0,0 +1,34 @@
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;
/**
* Per-player skin override state. Empty hash = vanilla skin.
*/
public record SkinData(String hash, SkinModel model) {
public static final SkinData EMPTY = new SkinData("", SkinModel.WIDE);
public static final Codec<SkinData> CODEC = RecordCodecBuilder.create(inst -> inst.group(
Codec.STRING.optionalFieldOf("hash", "").forGetter(SkinData::hash),
Codec.STRING.xmap(
s -> "slim".equalsIgnoreCase(s) ? SkinModel.SLIM : SkinModel.WIDE,
m -> m == SkinModel.SLIM ? "slim" : "wide"
).optionalFieldOf("model", SkinModel.WIDE).forGetter(SkinData::model)
).apply(inst, SkinData::new));
public static final StreamCodec<ByteBuf, SkinData> STREAM_CODEC = StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, SkinData::hash,
SkinModel.STREAM_CODEC, SkinData::model,
SkinData::new
);
public boolean isEmpty() {
return hash.isEmpty();
}
}

@ -0,0 +1,12 @@
package com.razz.dfashion.skin;
import io.netty.buffer.ByteBuf;
import net.minecraft.network.codec.ByteBufCodecs;
import net.minecraft.network.codec.StreamCodec;
public enum SkinModel {
WIDE, SLIM;
public static final StreamCodec<ByteBuf, SkinModel> STREAM_CODEC =
ByteBufCodecs.BOOL.map(b -> b ? SLIM : WIDE, m -> m == SLIM);
}

@ -0,0 +1,127 @@
package com.razz.dfashion.skin;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.packet.AssignSkin;
import com.razz.dfashion.skin.packet.RequestSkin;
import com.razz.dfashion.skin.packet.SkinChunk;
import com.razz.dfashion.skin.packet.UploadSkinChunk;
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.network.event.RegisterPayloadHandlersEvent;
import net.neoforged.neoforge.network.handling.IPayloadContext;
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
import java.io.IOException;
@EventBusSubscriber(modid = DecoFashion.MODID)
public final class SkinNetwork {
private static final String VERSION = "1";
@SubscribeEvent
static void onRegister(RegisterPayloadHandlersEvent event) {
PayloadRegistrar registrar = event.registrar(VERSION);
registrar.playToServer(
UploadSkinChunk.TYPE, UploadSkinChunk.STREAM_CODEC, SkinNetwork::onUploadChunk
);
registrar.playToServer(
RequestSkin.TYPE, RequestSkin.STREAM_CODEC, SkinNetwork::onRequestSkin
);
registrar.playToServer(
AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin
);
registrar.playToClient(
SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk
);
}
private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return;
MinecraftServer server = player.level().getServer();
if (server == null) return;
SkinData finalized = SkinCache.acceptChunk(
server, player.getUUID(), msg.index(), msg.total(), msg.model(), msg.data()
);
if (finalized != null) {
player.setData(SkinAttachments.DATA.get(), finalized);
}
});
}
private static void onAssignSkin(AssignSkin msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return;
MinecraftServer server = player.level().getServer();
if (server == null) return;
if (msg.hash().isEmpty()) {
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
return;
}
if (!SkinCache.has(server, msg.hash())) {
DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash());
return;
}
player.setData(SkinAttachments.DATA.get(), new SkinData(msg.hash(), msg.model()));
});
}
private static void onRequestSkin(RequestSkin msg, IPayloadContext ctx) {
ctx.enqueueWork(() -> {
if (!(ctx.player() instanceof ServerPlayer player)) return;
MinecraftServer server = player.level().getServer();
if (server == null) return;
if (!SkinCache.has(server, msg.hash())) {
DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash());
return;
}
byte[] png;
try {
png = SkinCache.read(server, msg.hash());
} catch (IOException ex) {
DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex);
return;
}
int chunkSize = SkinCache.MAX_CHUNK_BYTES;
int total = Math.max(1, (png.length + chunkSize - 1) / chunkSize);
for (int i = 0; i < total; i++) {
int off = i * chunkSize;
int len = Math.min(chunkSize, png.length - off);
byte[] slice = new byte[len];
System.arraycopy(png, off, slice, 0, len);
player.connection.send(new ClientboundCustomPayloadPacket(
new SkinChunk(msg.hash(), i, total, slice)));
}
});
}
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));
}
/**
* 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.
*/
private static final class ClientSkinChunkReceiver {
static void accept(SkinChunk msg) {
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
com.razz.dfashion.client.ClientSkinCache.onChunk(
msg.hash(), msg.index(), msg.total(), msg.data()
);
}
}
private SkinNetwork() {}
}

@ -0,0 +1,32 @@
package com.razz.dfashion.skin.packet;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SkinModel;
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. Assign an already-uploaded skin by hash without resending bytes.
* Server rejects if the hash isn't in its cache; reset if {@code hash} is empty.
*/
public record AssignSkin(String hash, SkinModel model) implements CustomPacketPayload {
public static final Type<AssignSkin> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin"));
public static final StreamCodec<FriendlyByteBuf, AssignSkin> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, AssignSkin::hash,
SkinModel.STREAM_CODEC, AssignSkin::model,
AssignSkin::new
);
@Override
public Type<AssignSkin> type() {
return TYPE;
}
}

@ -0,0 +1,30 @@
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. Asks the server to stream back the PNG bytes for a skin hash
* the client doesn't have cached locally.
*/
public record RequestSkin(String hash) implements CustomPacketPayload {
public static final Type<RequestSkin> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_skin"));
public static final StreamCodec<FriendlyByteBuf, RequestSkin> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, RequestSkin::hash,
RequestSkin::new
);
@Override
public Type<RequestSkin> type() {
return TYPE;
}
}

@ -0,0 +1,33 @@
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;
/**
* Server client. One chunk of the PNG bytes for a requested skin hash.
*/
public record SkinChunk(String hash, int index, int total, byte[] data)
implements CustomPacketPayload {
public static final Type<SkinChunk> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skin_chunk"));
public static final StreamCodec<FriendlyByteBuf, SkinChunk> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.STRING_UTF8, SkinChunk::hash,
ByteBufCodecs.VAR_INT, SkinChunk::index,
ByteBufCodecs.VAR_INT, SkinChunk::total,
ByteBufCodecs.BYTE_ARRAY, SkinChunk::data,
SkinChunk::new
);
@Override
public Type<SkinChunk> type() {
return TYPE;
}
}

@ -0,0 +1,36 @@
package com.razz.dfashion.skin.packet;
import com.razz.dfashion.DecoFashion;
import com.razz.dfashion.skin.SkinModel;
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 skin upload. When {@code index + 1 == total},
* the server finalizes, hashes, writes to cache, and updates the player's SkinData.
* Chunk data is raw PNG bytes; the server reassembles in order.
*/
public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data)
implements CustomPacketPayload {
public static final Type<UploadSkinChunk> TYPE =
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "upload_skin_chunk"));
public static final StreamCodec<FriendlyByteBuf, UploadSkinChunk> STREAM_CODEC =
StreamCodec.composite(
ByteBufCodecs.VAR_INT, UploadSkinChunk::index,
ByteBufCodecs.VAR_INT, UploadSkinChunk::total,
SkinModel.STREAM_CODEC, UploadSkinChunk::model,
ByteBufCodecs.BYTE_ARRAY, UploadSkinChunk::data,
UploadSkinChunk::new
);
@Override
public Type<UploadSkinChunk> type() {
return TYPE;
}
}
Loading…
Cancel
Save