Skin system and server sync and configuration added
This commit is contained in:
@@ -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 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
int rowLeft;
|
||||||
|
if (SKIN_TAB.equals(selectedCategory)) {
|
||||||
|
// 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) {
|
if (selectedCategory != null) {
|
||||||
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
for (Map.Entry<Identifier, CosmeticDefinition> entry : CosmeticCache.catalog.entrySet()) {
|
||||||
if (selectedCategory.equals(entry.getValue().category())) count++;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user