Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f488a89
feat: add Minecraft capes and cape override handling to the wardrobe
Deftu Oct 14, 2026
577c935
fix: resync local equipment when another client changes it
Deftu Oct 14, 2026
c80da87
feat: rebuild the wardrobe layout and merge the store into it
Deftu Oct 14, 2026
cd81e0b
feat: add an emote wheel editor with multiple wheels
Deftu Oct 14, 2026
f9279d4
feat: rename the cosmetics screen to Wardrobe and add a keybind to op…
Deftu Oct 14, 2026
d4794a9
fix: hide the cape in legacy previews when none is selected
Deftu Oct 14, 2026
621844f
feat: let player previews share a persistent rotation
Deftu Oct 14, 2026
3db3c6c
feat: build variant swatch art from cosmetic textures
Deftu Oct 14, 2026
f37e896
feat: detect emote wheel keybind conflicts
Deftu Oct 14, 2026
68faf09
feat: add variant swatches, card menus and a popularity sort to the w…
Deftu Oct 14, 2026
fb041a7
fix: fall back to the offline UUID when the session has none
Deftu Oct 14, 2026
83456e9
feat: add outfit and skin libraries backed by the PolyPlus API
Deftu Oct 14, 2026
5ef63bb
feat: add outfits, skins and an outfit switcher to the wardrobe
Deftu Oct 14, 2026
7a121dc
feat: replace the wardrobe sidebar with header chip rows
Deftu Oct 14, 2026
29cd6c8
feat: wrap cosmetic variants as chips across the full width
Deftu Oct 14, 2026
959ae02
feat: show four cards per wardrobe row
Deftu Oct 14, 2026
178ff53
feat: rework the cape headings and force toggle
Deftu Oct 14, 2026
8722e02
feat: give players eight starter outfits
Deftu Oct 14, 2026
36d4810
fix: drop outfits the server no longer has
Deftu Oct 14, 2026
41beb77
fix: read store prices from the sale object
Deftu Oct 14, 2026
1d863f7
fix: address wardrobe review feedback
Deftu Oct 15, 2026
5bce3c3
fix: harden outfit adoption, cape state and store loading
Deftu Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

//? if > 1.8.9 {
import com.llamalad7.mixinextras.injector.ModifyReturnValue;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.client.player.AbstractClientPlayer;
import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache;
import org.polyfrost.polyplus.client.cosmetics.CapeResolver;
import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment;
import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess;
import org.polyfrost.polyplus.client.cosmetics.access.PlayerEmotesAccess;
Expand All @@ -17,15 +18,14 @@
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;

//? if >= 1.21.10 {
import net.minecraft.core.ClientAsset;
import net.minecraft.world.entity.player.PlayerSkin;
//?}

//? if < 1.21.10 {
/*import net.minecraft.client.resources.PlayerSkin;
*///?}

@Mixin(AbstractClientPlayer.class)
@Mixin(value = AbstractClientPlayer.class, priority = 1500)
public abstract class MixinAbstractClientPlayer implements PlayerEmotesAccess, PlayerCosmeticsAccess {
@Shadow
private PlayerInfo playerInfo;
Expand Down Expand Up @@ -67,50 +67,37 @@ public abstract class MixinAbstractClientPlayer implements PlayerEmotesAccess, P
return original;
}

GameProfile profile = this.playerInfo.getProfile();
//~ if >= 1.21.10 'getId' -> 'id'
var capeLocation = CosmeticAssetCache.getCapeTexture(this.playerInfo.getProfile().id());
if (capeLocation == null) {
return original;
}

return new PlayerSkin(
// FIXME: how to simplify this
//? if >= 1.21.10 {
original.body(),
new ClientAsset.ResourceTexture(capeLocation),
original.elytra(),
original.model(),
original.secure()
//?} else {
/*original.texture(),
original.textureUrl(),
capeLocation,
original.elytraTexture(),
original.model(),
original.secure()
*///?}
);
return CapeResolver.applyCape(profile.id(), profile, original);
}
}
//?} else {
/*import com.mojang.authlib.GameProfile;
/*import com.llamalad7.mixinextras.injector.ModifyReturnValue;
import com.mojang.authlib.GameProfile;
import net.minecraft.client.entity.living.player.ClientPlayerEntity;
import net.minecraft.client.network.PlayerInfo;
import net.minecraft.entity.living.player.PlayerEntity;
import net.minecraft.resources.Identifier;
import net.minecraft.world.World;
import org.polyfrost.polyplus.client.PolyPlusConfig;
import org.polyfrost.polyplus.client.cosmetics.CapeResolver;
import org.polyfrost.polyplus.client.cosmetics.CosmeticAssetCache;
import org.polyfrost.polyplus.client.cosmetics.CosmeticEquipment;
import org.polyfrost.polyplus.client.cosmetics.MojangCapeService;
import org.polyfrost.polyplus.client.cosmetics.access.PlayerCosmeticsAccess;
import org.polyfrost.polyplus.client.cosmetics.access.PlayerEmotesAccess;
import org.polyfrost.polyplus.client.emotes.playback.EmoteController;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Shadow;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;

@Mixin(ClientPlayerEntity.class)
import java.util.UUID;

@Mixin(value = ClientPlayerEntity.class, priority = 1500)
public abstract class MixinAbstractClientPlayer extends PlayerEntity implements PlayerEmotesAccess, PlayerCosmeticsAccess {
@Unique
private EmoteController polyplus$emoteController;
Expand All @@ -122,6 +109,9 @@ private MixinAbstractClientPlayer(World world, GameProfile profile) {
super(world, profile);
}

@Shadow
protected abstract PlayerInfo getInfo();

@Override
public EmoteController polyplus$emoteController() {
EmoteController controller = polyplus$emoteController;
Expand All @@ -148,10 +138,27 @@ public void tick() {
polyplus$emoteController().tick((ClientPlayerEntity) (Object) this);
}

@Inject(method = "getCapeTextureLocation", at = @At("HEAD"), cancellable = true)
private void polyplus$replaceCapeTexture(CallbackInfoReturnable<Identifier> cir) {
Identifier cape = CosmeticAssetCache.getCapeTexture(((ClientPlayerEntity) (Object) this).getUuid());
if (cape != null) cir.setReturnValue(cape);
@ModifyReturnValue(method = "getCapeTextureLocation", at = @At("RETURN"))
private Identifier polyplus$replaceCapeTexture(Identifier original) {
UUID playerId = ((ClientPlayerEntity) (Object) this).getUuid();
switch (CapeResolver.decide(playerId, original == null ? null : original.getNamespace())) {
case POLYPLUS:
return polyplus$orOriginal(CosmeticAssetCache.getCapeTexture(playerId), original);
case MOJANG:
return polyplus$orOriginal(MojangCapeService.localCapeTexture(), original);
case VANILLA:
PlayerInfo info = getInfo();
return info == null ? null : info.getCapeTexture();
case NONE:
return null;
default:
return original;
}
}

@Unique
private static Identifier polyplus$orOriginal(Identifier cape, Identifier original) {
return cape != null ? cape : original;
}

@Inject(method = "getFovModifier", at = @At("HEAD"), cancellable = true)
Expand Down
13 changes: 13 additions & 0 deletions src/main/kotlin/org/polyfrost/polyplus/client/PolyPlusConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import org.polyfrost.oneconfig.api.ui.v1.keybind.OneConfigKeybind
import org.polyfrost.polyplus.BackendUrl
import org.polyfrost.polyplus.PolyPlusConstants
import org.polyfrost.polyplus.client.emotes.EmoteWheelKeybind
import org.polyfrost.polyplus.client.gui.PolyPlusOneConfigIntegration
import org.polyfrost.polyplus.client.network.websocket.PolyConnection
import org.polyfrost.polyplus.client.privacy.PrivacyEnforcement
import org.polyfrost.polyplus.client.social.SocialOverlay
Expand Down Expand Up @@ -224,6 +225,18 @@ object PolyPlusConfig : Config("${PolyPlusConstants.ID}.json", "${PolyPlusConsta
true
}

@Keybind(
title = "Open Wardrobe",
subcategory = "Keybinds",
description = "Open the PolyPlus wardrobe",
)
var wardrobeKeybind = OneConfigKeybind(null, null, KeyModifiers.NONE, 0L) { state ->
if (state) {
PolyPlusOneConfigIntegration.openCosmeticsFromKeybind()
}
true
}

@Dropdown(title = "API URL", description = "The URL used for the PolyPlus API. Only change if you know what you're doing.")
var apiUrl: BackendUrl = BackendUrl.PRODUCTION
get() = if (PolyPlusConstants.IS_DEV_ENV) field else BackendUrl.PRODUCTION
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ object PolyPlusCosmeticsConfig : Config(
)
var hideFeetCosmeticsWithBoots = true

@JvmStatic
@Switch(
title = "Force PolyPlus Capes",
description = "Show the capes picked in the wardrobe even when another mod sets its own cape.",
)
var forcePolyPlusCapes = false

@JvmStatic
@Dropdown(
title = "Capes",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ object PolyPlusMainMenuConfig : Config(

@JvmStatic
@Switch(
title = "Hide Cosmetics Button",
description = "Hide the Cosmetics button on the PolyPlus main menu.",
title = "Hide Wardrobe Button",
description = "Hide the Wardrobe button on the PolyPlus main menu.",
subcategory = "Elements",
)
var hideMainMenuCosmetics = false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package org.polyfrost.polyplus.client.cosmetics

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import net.fabricmc.loader.api.FabricLoader
import org.polyfrost.polyplus.PolyPlusConstants
import org.polyfrost.polyplus.client.PolyPlusCosmeticsConfig
import org.polyfrost.polyplus.client.utils.ClientPlatform
import java.util.UUID

//? if > 1.8.9 {
import com.mojang.authlib.GameProfile
import net.minecraft.client.Minecraft
import net.minecraft.resources.Identifier
//?}
//? if >= 1.21.10 {
import net.minecraft.core.ClientAsset
import net.minecraft.world.entity.player.PlayerSkin
//?} elif > 1.8.9 {
/*import net.minecraft.client.resources.PlayerSkin
*///?}

enum class CapeDecision {
KEEP,
POLYPLUS,
MOJANG,
VANILLA,
NONE,
}

object CapeResolver {
private const val VANILLA_NAMESPACE = "minecraft"

var localOverrideModId by mutableStateOf<String?>(null)
private set

val localOverrideModName: String?
get() = localOverrideModId?.let { id ->
FabricLoader.getInstance().getModContainer(id).map { it.metadata.name }.orElse(id)
}

@JvmStatic
fun decide(playerId: UUID, currentNamespace: String?): CapeDecision {
val foreign = currentNamespace != null &&
currentNamespace != VANILLA_NAMESPACE &&
currentNamespace != PolyPlusConstants.ID
val local = isLocal(playerId)
if (local) {
val modId = if (foreign) currentNamespace else null
if (localOverrideModId != modId) localOverrideModId = modId
}

if (foreign && !PolyPlusCosmeticsConfig.forcePolyPlusCapes) return CapeDecision.KEEP
if (CosmeticAssetCache.getCapeTexture(playerId) != null) return CapeDecision.POLYPLUS

val mojangKnown = MojangCapeService.overridesLocalCape(playerId) ||
(foreign && local && MojangCapeService.isLoaded())
if (mojangKnown) {
if (MojangCapeService.localCapeHidden()) return CapeDecision.NONE
if (MojangCapeService.localCapeTexture() != null) return CapeDecision.MOJANG
}
return if (foreign && local) CapeDecision.VANILLA else CapeDecision.KEEP
}

//? if > 1.8.9 {
@JvmStatic
fun applyCape(playerId: UUID, profile: GameProfile, original: PlayerSkin): PlayerSkin =
when (decide(playerId, capeNamespace(original))) {
CapeDecision.POLYPLUS -> withCape(original, CosmeticAssetCache.getCapeTexture(playerId))
CapeDecision.MOJANG -> withCape(original, MojangCapeService.localCapeTexture())
CapeDecision.VANILLA -> withCapeFrom(original, vanillaSkin(profile))
CapeDecision.NONE -> withCapeFrom(original, null)
CapeDecision.KEEP -> original
}

private fun vanillaSkin(profile: GameProfile): PlayerSkin? = runCatching {
//? if >= 1.21.10 {
Minecraft.getInstance().skinManager.createLookup(profile, false).get()
//?} else {
/*Minecraft.getInstance().skinManager.getInsecureSkin(profile)
*///?}
}.getOrNull()

private fun capeNamespace(skin: PlayerSkin): String? =
//? if >= 1.21.10 {
skin.cape()?.texturePath()?.namespace
//?} else {
/*skin.capeTexture()?.namespace
*///?}

private fun withCape(original: PlayerSkin, cape: Identifier?): PlayerSkin {
if (cape == null) return original
//? if >= 1.21.10 {
return PlayerSkin(original.body(), ClientAsset.ResourceTexture(cape), original.elytra(), original.model(), original.secure())
//?} else {
/*return PlayerSkin(original.texture(), original.textureUrl(), cape, original.elytraTexture(), original.model(), original.secure())
*///?}
}

private fun withCapeFrom(original: PlayerSkin, source: PlayerSkin?): PlayerSkin =
//? if >= 1.21.10 {
PlayerSkin(original.body(), source?.cape(), original.elytra(), original.model(), original.secure())
//?} else {
/*PlayerSkin(original.texture(), original.textureUrl(), source?.capeTexture(), original.elytraTexture(), original.model(), original.secure())
*///?}
//?}

private fun isLocal(playerId: UUID): Boolean =
runCatching { ClientPlatform.localPlayerUuid() }.getOrNull() == playerId
}
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,17 @@ object CosmeticAssetCache {
CosmeticType.Emote -> "emote-$id"
else -> "cosmetic-$id"
}

fun primaryTextureFile(id: Int): File? {
val definition = CosmeticCatalog.getCosmeticDefinition(id) ?: return null
val dir = baseDir.resolve(definition.cacheKey())
if (!dir.isDirectory) return null
return dir.walkTopDown()
.filter { it.isFile && !it.name.startsWith(APPLE_DOUBLE_PREFIX) && it.extension.equals("png", ignoreCase = true) }
.sortedBy { it.invariantSeparatorsPath }
.firstOrNull()
?: dir.resolve("asset.bin").takeIf { it.isFile }
}
}

private const val MAX_CAPE_FRAME_PIXELS = 1024L * 512
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ object CosmeticCatalog {
}.bodyOrThrow<CosmeticList>()
}.onFailure { reportCatalogFailure(it) }
.getOrElse {
CosmeticLoadProgress.fail(it.message ?: it::class.java.simpleName)
CosmeticLoadProgress.fail(
if (isRuntimeMismatch(it)) CosmeticLoadProgress.OUTDATED_RUNTIME else it.message ?: it::class.java.simpleName,
)
return
}
CosmeticLoadProgress.onCatalogFetched()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ object CosmeticLoadProgress {

private const val ASSET_WEIGHT = 0.75f

const val OUTDATED_RUNTIME = "Fabric Language Kotlin is out of date"

data class Snapshot(
val started: Boolean,
val ready: Boolean,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.polyfrost.polyplus.client.cosmetics

import org.apache.logging.log4j.LogManager
import org.polyfrost.polyplus.client.cosmetics.outfits.OutfitLibrary
import org.polyfrost.polyplus.client.network.http.responses.BodySlot
import org.polyfrost.polyplus.client.network.http.responses.CosmeticType
import org.polyfrost.polyplus.client.network.http.responses.PartialEquippedCosmetics
Expand All @@ -26,6 +27,7 @@ object CosmeticService {
}
}
PolyConnection.sendPacket(ServerboundPacket.SetParticleColor(color)).getOrThrow()
OutfitLibrary.onParticleColorChanged(color)
}

suspend fun clearEmote(): Result<Unit> = runCatching {
Expand Down
Loading
Loading