diff --git a/src/main/java/com/razz/dfashion/DecoFashion.java b/src/main/java/com/razz/dfashion/DecoFashion.java index e5dd1b6..a0e1230 100644 --- a/src/main/java/com/razz/dfashion/DecoFashion.java +++ b/src/main/java/com/razz/dfashion/DecoFashion.java @@ -5,6 +5,7 @@ import org.slf4j.Logger; import com.mojang.logging.LogUtils; import com.razz.dfashion.block.ClosetRegistry; import com.razz.dfashion.cosmetic.CosmeticAttachments; +import com.razz.dfashion.skin.SkinAttachments; import net.neoforged.bus.api.IEventBus; import net.neoforged.fml.ModContainer; @@ -19,6 +20,7 @@ public class DecoFashion { public DecoFashion(IEventBus modEventBus, ModContainer modContainer) { modEventBus.addListener(this::commonSetup); CosmeticAttachments.ATTACHMENT_TYPES.register(modEventBus); + SkinAttachments.ATTACHMENT_TYPES.register(modEventBus); ClosetRegistry.register(modEventBus); } diff --git a/src/main/java/com/razz/dfashion/DecoFashionClient.java b/src/main/java/com/razz/dfashion/DecoFashionClient.java index 5abe5de..5fd2e6a 100644 --- a/src/main/java/com/razz/dfashion/DecoFashionClient.java +++ b/src/main/java/com/razz/dfashion/DecoFashionClient.java @@ -7,16 +7,22 @@ import com.razz.dfashion.bbmodel.BbOutlinerNode; import com.razz.dfashion.bbmodel.Bbmodel; import com.razz.dfashion.bbmodel.BbmodelBaker; import com.razz.dfashion.block.ClosetRegistry; +import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.ClosetModelCache; import com.razz.dfashion.client.ClosetRenderer; import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticRenderLayer; import com.razz.dfashion.cosmetic.CosmeticCatalog; 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.player.AbstractClientPlayer; 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.server.packs.resources.Resource; 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.Mod; 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.EntityRenderersEvent; import java.io.IOException; +import java.io.InputStream; import java.io.Reader; +import java.nio.file.Files; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -68,17 +78,69 @@ public class DecoFashionClient { event.registerBlockEntityRenderer(ClosetRegistry.CLOSET_BE.get(), ClosetRenderer::new); } - static void load(ResourceManager rm) { + public static void load(ResourceManager rm) { Map catalog = CosmeticCatalog.loadAll(rm); Map baked = new HashMap<>(); for (Map.Entry entry : catalog.entrySet()) { loadOne(rm, entry.getKey(), entry.getValue(), baked); } + loadUserCosmetics(catalog, baked); CosmeticCache.cosmetics = baked; CosmeticCache.catalog = catalog; DecoFashion.LOGGER.info("DecoFashion: {} cosmetic(s) loaded", baked.size()); 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 /decofashion/cosmetics///} 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 catalog, + Map baked + ) { + List entries = UserCosmeticLoader.scan(FMLPaths.GAMEDIR.get()); + if (entries.isEmpty()) return; + + TextureManager tm = Minecraft.getInstance().getTextureManager(); + Map 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 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()); + } } /** diff --git a/src/main/java/com/razz/dfashion/client/ClientCommands.java b/src/main/java/com/razz/dfashion/client/ClientCommands.java new file mode 100644 index 0000000..f283bb2 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClientCommands.java @@ -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 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 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 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() {} +} diff --git a/src/main/java/com/razz/dfashion/client/ClientSkinCache.java b/src/main/java/com/razz/dfashion/client/ClientSkinCache.java new file mode 100644 index 0000000..4181e41 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/ClientSkinCache.java @@ -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 /decofashion/skins_cache/.png}, + * registers them as {@link DynamicTexture}s under synthetic {@code decofashion:skins/} 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 REGISTERED = new ConcurrentHashMap<>(); + private static final Map 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 snapshotRegistered() { + return new HashMap<>(REGISTERED); + } + + /** + * Register every cached PNG under {@code /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 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 hash→id 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)); + } +} diff --git a/src/main/java/com/razz/dfashion/client/SkinInfoOverride.java b/src/main/java/com/razz/dfashion/client/SkinInfoOverride.java new file mode 100644 index 0000000..8f21618 --- /dev/null +++ b/src/main/java/com/razz/dfashion/client/SkinInfoOverride.java @@ -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. + * + *

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 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 original = current instanceof OverrideLookup ol ? ol.original : current; + setLookup(info, new OverrideLookup(original, tex, data.hash(), desired)); + } + + @SuppressWarnings("unchecked") + private static Supplier getLookup(PlayerInfo info) { + try { + return (Supplier) SKIN_LOOKUP_FIELD.get(info); + } catch (IllegalAccessException ex) { + return null; + } + } + + private static void setLookup(PlayerInfo info, Supplier 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 { + final Supplier original; + final Identifier texture; + final String hash; + final PlayerModelType model; + + OverrideLookup(Supplier 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() {} +} diff --git a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java index 384f060..08ef227 100644 --- a/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java +++ b/src/main/java/com/razz/dfashion/client/screen/ClosetScreen.java @@ -1,10 +1,20 @@ package com.razz.dfashion.client.screen; import com.razz.dfashion.block.ClosetBlockEntity; +import com.razz.dfashion.client.ClientSkinCache; import com.razz.dfashion.client.CosmeticCache; import com.razz.dfashion.client.CosmeticRenderLayer; +import com.razz.dfashion.client.SkinInfoOverride; import com.razz.dfashion.cosmetic.CosmeticAttachments; 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.CameraType; @@ -25,6 +35,10 @@ import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.client.event.ClientTickEvent; 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.joml.Matrix4f; import org.joml.Quaternionf; @@ -32,15 +46,18 @@ import org.joml.Vector3f; import org.lwjgl.glfw.GLFW; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; 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 = { - "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). private static final float ROTATE_PER_TICK = 6f; @@ -74,6 +91,18 @@ public class ClosetScreen extends Screen { private final List