Skin system and server sync and configuration added
parent
42994b15a3
commit
e8dfa02160
@ -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() {}
|
||||||
|
}
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
package com.razz.dfashion.cosmetic;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.DirectoryStream;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans {@code <gameDir>/decofashion/cosmetics/<category>/<folder>/} for user-authored
|
||||||
|
* cosmetics. One folder per cosmetic; category = parent dir name; variants = extra PNGs.
|
||||||
|
*/
|
||||||
|
public final class UserCosmeticLoader {
|
||||||
|
|
||||||
|
public static final String USER_NAMESPACE = "decofashion_user";
|
||||||
|
private static final String ROOT_DIR = "decofashion/cosmetics";
|
||||||
|
|
||||||
|
/** One scan result — a concrete (id, def, on-disk model file, on-disk texture file) tuple. */
|
||||||
|
public record Entry(
|
||||||
|
Identifier id,
|
||||||
|
CosmeticDefinition def,
|
||||||
|
Path modelFile,
|
||||||
|
Path textureFile
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private UserCosmeticLoader() {}
|
||||||
|
|
||||||
|
public static List<Entry> scan(Path gameDir) {
|
||||||
|
List<Entry> out = new ArrayList<>();
|
||||||
|
Path root = gameDir.resolve(ROOT_DIR);
|
||||||
|
|
||||||
|
if (!Files.isDirectory(root)) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(root);
|
||||||
|
DecoFashion.LOGGER.info("Created user cosmetics folder at {}", root);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("Couldn't create user cosmetics folder {}: {}",
|
||||||
|
root, ex.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (DirectoryStream<Path> categories = Files.newDirectoryStream(root)) {
|
||||||
|
for (Path categoryDir : categories) {
|
||||||
|
if (!Files.isDirectory(categoryDir)) continue;
|
||||||
|
String category = normalize(categoryDir.getFileName().toString());
|
||||||
|
if (category.isEmpty()) continue;
|
||||||
|
|
||||||
|
try (DirectoryStream<Path> folders = Files.newDirectoryStream(categoryDir)) {
|
||||||
|
for (Path folder : folders) {
|
||||||
|
if (!Files.isDirectory(folder)) continue;
|
||||||
|
scanOne(folder, category, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Failed scanning user cosmetics under {}", root, ex);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scanOne(Path cosmeticDir, String category, List<Entry> out) throws IOException {
|
||||||
|
String rawFolder = cosmeticDir.getFileName().toString();
|
||||||
|
String folderId = normalize(rawFolder);
|
||||||
|
if (folderId.isEmpty()) return;
|
||||||
|
|
||||||
|
Path modelFile = null;
|
||||||
|
List<Path> pngs = new ArrayList<>();
|
||||||
|
|
||||||
|
try (DirectoryStream<Path> files = Files.newDirectoryStream(cosmeticDir)) {
|
||||||
|
for (Path f : files) {
|
||||||
|
if (!Files.isRegularFile(f)) continue;
|
||||||
|
String name = f.getFileName().toString().toLowerCase(Locale.ROOT);
|
||||||
|
if (name.endsWith(".bbmodel")) {
|
||||||
|
if (modelFile == null) modelFile = f;
|
||||||
|
} else if (name.endsWith(".png")) {
|
||||||
|
pngs.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelFile == null) {
|
||||||
|
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .bbmodel, skipping", category, rawFolder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pngs.isEmpty()) {
|
||||||
|
DecoFashion.LOGGER.warn("User cosmetic {}/{}: no .png, skipping", category, rawFolder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pngs.sort(Comparator.comparing(p -> p.getFileName().toString().toLowerCase(Locale.ROOT)));
|
||||||
|
|
||||||
|
// Single texture that matches the folder name → un-suffixed id/display.
|
||||||
|
boolean singleMatching = pngs.size() == 1
|
||||||
|
&& stem(pngs.get(0)).equalsIgnoreCase(rawFolder);
|
||||||
|
|
||||||
|
for (Path png : pngs) {
|
||||||
|
String textureId = normalize(stem(png));
|
||||||
|
if (textureId.isEmpty()) continue;
|
||||||
|
|
||||||
|
String idPath;
|
||||||
|
String displayName;
|
||||||
|
if (singleMatching) {
|
||||||
|
idPath = folderId;
|
||||||
|
displayName = titleCase(folderId);
|
||||||
|
} else {
|
||||||
|
idPath = folderId + "_" + textureId;
|
||||||
|
displayName = titleCase(folderId) + " " + titleCase(textureId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Identifier id = Identifier.fromNamespaceAndPath(USER_NAMESPACE, idPath);
|
||||||
|
Identifier modelRef = Identifier.fromNamespaceAndPath(
|
||||||
|
USER_NAMESPACE, "cosmetic/" + idPath + ".bbmodel");
|
||||||
|
Identifier textureRef = Identifier.fromNamespaceAndPath(
|
||||||
|
USER_NAMESPACE, "textures/cosmetic/" + idPath + ".png");
|
||||||
|
|
||||||
|
CosmeticDefinition def = new CosmeticDefinition(displayName, category, modelRef, textureRef);
|
||||||
|
out.add(new Entry(id, def, modelFile, png));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String stem(Path file) {
|
||||||
|
String name = file.getFileName().toString();
|
||||||
|
int dot = name.lastIndexOf('.');
|
||||||
|
return dot < 0 ? name : name.substring(0, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** lowercase; spaces/hyphens → underscore; strip anything outside [a-z0-9_]. */
|
||||||
|
private static String normalize(String raw) {
|
||||||
|
StringBuilder sb = new StringBuilder(raw.length());
|
||||||
|
for (int i = 0; i < raw.length(); i++) {
|
||||||
|
char c = Character.toLowerCase(raw.charAt(i));
|
||||||
|
if (c == ' ' || c == '-') sb.append('_');
|
||||||
|
else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') sb.append(c);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** snake_case → Title Case. */
|
||||||
|
private static String titleCase(String id) {
|
||||||
|
if (id.isEmpty()) return id;
|
||||||
|
String[] parts = id.split("_");
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String part : parts) {
|
||||||
|
if (part.isEmpty()) continue;
|
||||||
|
if (sb.length() > 0) sb.append(' ');
|
||||||
|
sb.append(Character.toUpperCase(part.charAt(0)));
|
||||||
|
if (part.length() > 1) sb.append(part.substring(1));
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.neoforged.neoforge.attachment.AttachmentType;
|
||||||
|
import net.neoforged.neoforge.registries.DeferredHolder;
|
||||||
|
import net.neoforged.neoforge.registries.DeferredRegister;
|
||||||
|
import net.neoforged.neoforge.registries.NeoForgeRegistries;
|
||||||
|
|
||||||
|
public final class SkinAttachments {
|
||||||
|
|
||||||
|
public static final DeferredRegister<AttachmentType<?>> ATTACHMENT_TYPES =
|
||||||
|
DeferredRegister.create(NeoForgeRegistries.ATTACHMENT_TYPES, DecoFashion.MODID);
|
||||||
|
|
||||||
|
public static final DeferredHolder<AttachmentType<?>, AttachmentType<SkinData>> DATA =
|
||||||
|
ATTACHMENT_TYPES.register(
|
||||||
|
"skin_data",
|
||||||
|
() -> AttachmentType.<SkinData>builder(() -> SkinData.EMPTY)
|
||||||
|
.serialize(SkinData.CODEC.fieldOf("skin"))
|
||||||
|
.sync(SkinData.STREAM_CODEC)
|
||||||
|
.copyOnDeath()
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
|
||||||
|
private SkinAttachments() {}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side skin store. Keeps PNG bytes on disk under {@code <serverDir>/decofashion/skins/<hash>.png},
|
||||||
|
* and tracks in-flight multi-chunk uploads per player.
|
||||||
|
*/
|
||||||
|
public final class SkinCache {
|
||||||
|
|
||||||
|
public static final int MAX_BYTES = 32 * 1024 * 1024; // 32 MB
|
||||||
|
public static final int MAX_CHUNK_BYTES = 512 * 1024; // 512 KB
|
||||||
|
|
||||||
|
private static final String SUBDIR = "decofashion/skins";
|
||||||
|
|
||||||
|
private static final Map<UUID, Assembly> IN_FLIGHT = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private SkinCache() {}
|
||||||
|
|
||||||
|
/** Per-player chunk assembly state. */
|
||||||
|
private static final class Assembly {
|
||||||
|
int expectedTotal;
|
||||||
|
int nextIndex;
|
||||||
|
SkinModel model;
|
||||||
|
ByteArrayOutputStream buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path root(MinecraftServer server) {
|
||||||
|
return server.getServerDirectory().resolve(SUBDIR);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Path fileFor(MinecraftServer server, String hash) {
|
||||||
|
return root(server).resolve(hash + ".png");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean has(MinecraftServer server, String hash) {
|
||||||
|
return Files.isRegularFile(fileFor(server, hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] read(MinecraftServer server, String hash) throws IOException {
|
||||||
|
return Files.readAllBytes(fileFor(server, hash));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accept one chunk from a player. Returns the finalized {@link SkinData} with the
|
||||||
|
* computed hash once the last chunk lands, or {@code null} if more chunks are expected
|
||||||
|
* (or the upload was rejected — check logs).
|
||||||
|
*/
|
||||||
|
public static SkinData acceptChunk(
|
||||||
|
MinecraftServer server, UUID playerId,
|
||||||
|
int index, int total, SkinModel model, byte[] data
|
||||||
|
) {
|
||||||
|
if (total <= 0 || index < 0 || index >= total) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: invalid chunk {}/{}", playerId, index, total);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (data.length > MAX_CHUNK_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: chunk too large ({} > {})",
|
||||||
|
playerId, data.length, MAX_CHUNK_BYTES);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assembly asm;
|
||||||
|
if (index == 0) {
|
||||||
|
asm = new Assembly();
|
||||||
|
asm.expectedTotal = total;
|
||||||
|
asm.nextIndex = 0;
|
||||||
|
asm.model = model;
|
||||||
|
asm.buffer = new ByteArrayOutputStream(Math.min(total * MAX_CHUNK_BYTES, MAX_BYTES));
|
||||||
|
IN_FLIGHT.put(playerId, asm);
|
||||||
|
} else {
|
||||||
|
asm = IN_FLIGHT.get(playerId);
|
||||||
|
if (asm == null || asm.expectedTotal != total || asm.nextIndex != index) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: out-of-order chunk (got {}/{}, expected {}/{})",
|
||||||
|
playerId, index, total,
|
||||||
|
asm == null ? -1 : asm.nextIndex,
|
||||||
|
asm == null ? -1 : asm.expectedTotal);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (asm.buffer.size() + data.length > MAX_BYTES) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin upload from {}: exceeds size cap ({})", playerId, MAX_BYTES);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
asm.buffer.write(data);
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Skin upload from {}: buffer write failed", playerId, ex);
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
asm.nextIndex = index + 1;
|
||||||
|
|
||||||
|
if (index + 1 < total) return null;
|
||||||
|
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
|
||||||
|
byte[] png = asm.buffer.toByteArray();
|
||||||
|
String hash;
|
||||||
|
try {
|
||||||
|
hash = sha256Hex(png);
|
||||||
|
} catch (NoSuchAlgorithmException ex) {
|
||||||
|
DecoFashion.LOGGER.error("SHA-256 unavailable", ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Files.createDirectories(root(server));
|
||||||
|
Path target = fileFor(server, hash);
|
||||||
|
if (!Files.isRegularFile(target)) {
|
||||||
|
Files.write(target, png);
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Failed writing skin {}", hash, ex);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
DecoFashion.LOGGER.info("Skin upload finalized: player={} hash={} bytes={} model={}",
|
||||||
|
playerId, hash, png.length, asm.model);
|
||||||
|
return new SkinData(hash, asm.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void cancelInFlight(UUID playerId) {
|
||||||
|
IN_FLIGHT.remove(playerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recognized hashes from disk — useful for validation / admin tooling. */
|
||||||
|
public static Map<String, Long> listCached(MinecraftServer server) {
|
||||||
|
Map<String, Long> out = new HashMap<>();
|
||||||
|
Path dir = root(server);
|
||||||
|
if (!Files.isDirectory(dir)) return out;
|
||||||
|
try (var stream = Files.newDirectoryStream(dir, "*.png")) {
|
||||||
|
for (Path p : stream) {
|
||||||
|
String name = p.getFileName().toString();
|
||||||
|
String hash = name.substring(0, name.length() - 4);
|
||||||
|
try {
|
||||||
|
out.put(hash, Files.size(p));
|
||||||
|
} catch (IOException ignored) {}
|
||||||
|
}
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.warn("listCached failed: {}", ex.getMessage());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sha256Hex(byte[] in) throws NoSuchAlgorithmException {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] out = md.digest(in);
|
||||||
|
StringBuilder sb = new StringBuilder(out.length * 2);
|
||||||
|
for (byte b : out) sb.append(String.format("%02x", b));
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.mojang.brigadier.CommandDispatcher;
|
||||||
|
import com.mojang.brigadier.context.CommandContext;
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.commands.CommandSourceStack;
|
||||||
|
import net.minecraft.commands.Commands;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.fml.common.EventBusSubscriber;
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent;
|
||||||
|
|
||||||
|
@EventBusSubscriber(modid = DecoFashion.MODID)
|
||||||
|
public final class SkinCommands {
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onRegisterCommands(RegisterCommandsEvent event) {
|
||||||
|
CommandDispatcher<CommandSourceStack> dispatcher = event.getDispatcher();
|
||||||
|
dispatcher.register(
|
||||||
|
Commands.literal("decofashion_skin")
|
||||||
|
.then(Commands.literal("reset").executes(SkinCommands::reset))
|
||||||
|
.then(Commands.literal("show").executes(SkinCommands::show))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int reset(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
ServerPlayer player = ctx.getSource().getPlayer();
|
||||||
|
if (player == null) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Must be a player"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
||||||
|
ctx.getSource().sendSuccess(
|
||||||
|
() -> Component.literal("Skin reset to vanilla"), false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int show(CommandContext<CommandSourceStack> ctx) {
|
||||||
|
ServerPlayer player = ctx.getSource().getPlayer();
|
||||||
|
if (player == null) {
|
||||||
|
ctx.getSource().sendFailure(Component.literal("Must be a player"));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
SkinData d = player.getData(SkinAttachments.DATA.get());
|
||||||
|
ctx.getSource().sendSuccess(
|
||||||
|
() -> Component.literal("Skin: " + (d.isEmpty() ? "<vanilla>" : d.hash()) + " (" + d.model() + ")"),
|
||||||
|
false);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private SkinCommands() {}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.mojang.serialization.Codec;
|
||||||
|
import com.mojang.serialization.codecs.RecordCodecBuilder;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-player skin override state. Empty hash = vanilla skin.
|
||||||
|
*/
|
||||||
|
public record SkinData(String hash, SkinModel model) {
|
||||||
|
|
||||||
|
public static final SkinData EMPTY = new SkinData("", SkinModel.WIDE);
|
||||||
|
|
||||||
|
public static final Codec<SkinData> CODEC = RecordCodecBuilder.create(inst -> inst.group(
|
||||||
|
Codec.STRING.optionalFieldOf("hash", "").forGetter(SkinData::hash),
|
||||||
|
Codec.STRING.xmap(
|
||||||
|
s -> "slim".equalsIgnoreCase(s) ? SkinModel.SLIM : SkinModel.WIDE,
|
||||||
|
m -> m == SkinModel.SLIM ? "slim" : "wide"
|
||||||
|
).optionalFieldOf("model", SkinModel.WIDE).forGetter(SkinData::model)
|
||||||
|
).apply(inst, SkinData::new));
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, SkinData> STREAM_CODEC = StreamCodec.composite(
|
||||||
|
ByteBufCodecs.STRING_UTF8, SkinData::hash,
|
||||||
|
SkinModel.STREAM_CODEC, SkinData::model,
|
||||||
|
SkinData::new
|
||||||
|
);
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return hash.isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import io.netty.buffer.ByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
|
||||||
|
public enum SkinModel {
|
||||||
|
WIDE, SLIM;
|
||||||
|
|
||||||
|
public static final StreamCodec<ByteBuf, SkinModel> STREAM_CODEC =
|
||||||
|
ByteBufCodecs.BOOL.map(b -> b ? SLIM : WIDE, m -> m == SLIM);
|
||||||
|
}
|
||||||
@ -0,0 +1,127 @@
|
|||||||
|
package com.razz.dfashion.skin;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.skin.packet.AssignSkin;
|
||||||
|
import com.razz.dfashion.skin.packet.RequestSkin;
|
||||||
|
import com.razz.dfashion.skin.packet.SkinChunk;
|
||||||
|
import com.razz.dfashion.skin.packet.UploadSkinChunk;
|
||||||
|
|
||||||
|
import net.minecraft.network.protocol.common.ClientboundCustomPayloadPacket;
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.neoforged.api.distmarker.Dist;
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent;
|
||||||
|
import net.neoforged.fml.common.EventBusSubscriber;
|
||||||
|
import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent;
|
||||||
|
import net.neoforged.neoforge.network.handling.IPayloadContext;
|
||||||
|
import net.neoforged.neoforge.network.registration.PayloadRegistrar;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@EventBusSubscriber(modid = DecoFashion.MODID)
|
||||||
|
public final class SkinNetwork {
|
||||||
|
|
||||||
|
private static final String VERSION = "1";
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
static void onRegister(RegisterPayloadHandlersEvent event) {
|
||||||
|
PayloadRegistrar registrar = event.registrar(VERSION);
|
||||||
|
|
||||||
|
registrar.playToServer(
|
||||||
|
UploadSkinChunk.TYPE, UploadSkinChunk.STREAM_CODEC, SkinNetwork::onUploadChunk
|
||||||
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
RequestSkin.TYPE, RequestSkin.STREAM_CODEC, SkinNetwork::onRequestSkin
|
||||||
|
);
|
||||||
|
registrar.playToServer(
|
||||||
|
AssignSkin.TYPE, AssignSkin.STREAM_CODEC, SkinNetwork::onAssignSkin
|
||||||
|
);
|
||||||
|
registrar.playToClient(
|
||||||
|
SkinChunk.TYPE, SkinChunk.STREAM_CODEC, SkinNetwork::onSkinChunk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onUploadChunk(UploadSkinChunk msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
|
||||||
|
SkinData finalized = SkinCache.acceptChunk(
|
||||||
|
server, player.getUUID(), msg.index(), msg.total(), msg.model(), msg.data()
|
||||||
|
);
|
||||||
|
if (finalized != null) {
|
||||||
|
player.setData(SkinAttachments.DATA.get(), finalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onAssignSkin(AssignSkin msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
|
||||||
|
if (msg.hash().isEmpty()) {
|
||||||
|
player.setData(SkinAttachments.DATA.get(), SkinData.EMPTY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!SkinCache.has(server, msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("AssignSkin from {}: unknown hash {}", player.getUUID(), msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
player.setData(SkinAttachments.DATA.get(), new SkinData(msg.hash(), msg.model()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onRequestSkin(RequestSkin msg, IPayloadContext ctx) {
|
||||||
|
ctx.enqueueWork(() -> {
|
||||||
|
if (!(ctx.player() instanceof ServerPlayer player)) return;
|
||||||
|
MinecraftServer server = player.level().getServer();
|
||||||
|
if (server == null) return;
|
||||||
|
if (!SkinCache.has(server, msg.hash())) {
|
||||||
|
DecoFashion.LOGGER.warn("Skin request from {}: unknown hash {}", player.getUUID(), msg.hash());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] png;
|
||||||
|
try {
|
||||||
|
png = SkinCache.read(server, msg.hash());
|
||||||
|
} catch (IOException ex) {
|
||||||
|
DecoFashion.LOGGER.error("Skin request {}: read failed", msg.hash(), ex);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int chunkSize = SkinCache.MAX_CHUNK_BYTES;
|
||||||
|
int total = Math.max(1, (png.length + chunkSize - 1) / chunkSize);
|
||||||
|
for (int i = 0; i < total; i++) {
|
||||||
|
int off = i * chunkSize;
|
||||||
|
int len = Math.min(chunkSize, png.length - off);
|
||||||
|
byte[] slice = new byte[len];
|
||||||
|
System.arraycopy(png, off, slice, 0, len);
|
||||||
|
player.connection.send(new ClientboundCustomPayloadPacket(
|
||||||
|
new SkinChunk(msg.hash(), i, total, slice)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void onSkinChunk(SkinChunk msg, IPayloadContext ctx) {
|
||||||
|
// Defer client-side handling so we don't pull client classes into common code.
|
||||||
|
ctx.enqueueWork(() -> ClientSkinChunkReceiver.accept(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small trampoline to keep the client-only receiver out of the common-side packet router;
|
||||||
|
* the {@code @OnlyIn(CLIENT)} classes are referenced only through this dist-gated class.
|
||||||
|
*/
|
||||||
|
private static final class ClientSkinChunkReceiver {
|
||||||
|
static void accept(SkinChunk msg) {
|
||||||
|
if (net.neoforged.fml.loading.FMLEnvironment.getDist() != Dist.CLIENT) return;
|
||||||
|
com.razz.dfashion.client.ClientSkinCache.onChunk(
|
||||||
|
msg.hash(), msg.index(), msg.total(), msg.data()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SkinNetwork() {}
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.skin.SkinModel;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Assign an already-uploaded skin by hash without resending bytes.
|
||||||
|
* Server rejects if the hash isn't in its cache; reset if {@code hash} is empty.
|
||||||
|
*/
|
||||||
|
public record AssignSkin(String hash, SkinModel model) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<AssignSkin> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "assign_skin"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, AssignSkin> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.STRING_UTF8, AssignSkin::hash,
|
||||||
|
SkinModel.STREAM_CODEC, AssignSkin::model,
|
||||||
|
AssignSkin::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<AssignSkin> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. Asks the server to stream back the PNG bytes for a skin hash
|
||||||
|
* the client doesn't have cached locally.
|
||||||
|
*/
|
||||||
|
public record RequestSkin(String hash) implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<RequestSkin> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "request_skin"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, RequestSkin> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.STRING_UTF8, RequestSkin::hash,
|
||||||
|
RequestSkin::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<RequestSkin> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server → client. One chunk of the PNG bytes for a requested skin hash.
|
||||||
|
*/
|
||||||
|
public record SkinChunk(String hash, int index, int total, byte[] data)
|
||||||
|
implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<SkinChunk> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "skin_chunk"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, SkinChunk> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.STRING_UTF8, SkinChunk::hash,
|
||||||
|
ByteBufCodecs.VAR_INT, SkinChunk::index,
|
||||||
|
ByteBufCodecs.VAR_INT, SkinChunk::total,
|
||||||
|
ByteBufCodecs.BYTE_ARRAY, SkinChunk::data,
|
||||||
|
SkinChunk::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<SkinChunk> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package com.razz.dfashion.skin.packet;
|
||||||
|
|
||||||
|
import com.razz.dfashion.DecoFashion;
|
||||||
|
import com.razz.dfashion.skin.SkinModel;
|
||||||
|
|
||||||
|
import net.minecraft.network.FriendlyByteBuf;
|
||||||
|
import net.minecraft.network.codec.ByteBufCodecs;
|
||||||
|
import net.minecraft.network.codec.StreamCodec;
|
||||||
|
import net.minecraft.network.protocol.common.custom.CustomPacketPayload;
|
||||||
|
import net.minecraft.resources.Identifier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client → server. One chunk of a skin upload. When {@code index + 1 == total},
|
||||||
|
* the server finalizes, hashes, writes to cache, and updates the player's SkinData.
|
||||||
|
* Chunk data is raw PNG bytes; the server reassembles in order.
|
||||||
|
*/
|
||||||
|
public record UploadSkinChunk(int index, int total, SkinModel model, byte[] data)
|
||||||
|
implements CustomPacketPayload {
|
||||||
|
|
||||||
|
public static final Type<UploadSkinChunk> TYPE =
|
||||||
|
new Type<>(Identifier.fromNamespaceAndPath(DecoFashion.MODID, "upload_skin_chunk"));
|
||||||
|
|
||||||
|
public static final StreamCodec<FriendlyByteBuf, UploadSkinChunk> STREAM_CODEC =
|
||||||
|
StreamCodec.composite(
|
||||||
|
ByteBufCodecs.VAR_INT, UploadSkinChunk::index,
|
||||||
|
ByteBufCodecs.VAR_INT, UploadSkinChunk::total,
|
||||||
|
SkinModel.STREAM_CODEC, UploadSkinChunk::model,
|
||||||
|
ByteBufCodecs.BYTE_ARRAY, UploadSkinChunk::data,
|
||||||
|
UploadSkinChunk::new
|
||||||
|
);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Type<UploadSkinChunk> type() {
|
||||||
|
return TYPE;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue