Skip to content

feat: rebuild the wardrobe - #205

Open
Deftu wants to merge 22 commits into
mainfrom
feat/wardrobe
Open

Deftu wants to merge 22 commits into
mainfrom
feat/wardrobe

Conversation

@Deftu

@Deftu Deftu commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Rebuilds the cosmetics screen as the Wardrobe and moves the store into it. You can open it with its own keybind.

Layout

  • Header chip rows for the sections (Featured, Outfits, Skins, Emotes and Cosmetics) in place of the old sidebar
  • Four cards per row, with a menu on each card and variant swatches made from the cosmetic's own texture
  • Variant chips under the preview that wrap across the full width
  • Popular as a sort option, next to newest, price and name
  • One preview rotation shared between selections

Capes

Your Minecraft capes sit next to the PolyPlus ones, under their own headings. When another mod overrides capes, a toggle on the PolyPlus capes heading puts PolyPlus capes back on top.

Outfits and skins

  • Save outfits and switch between them from the preview
  • A skin library, backed by the PolyPlus API, that records your current Mojang skin after sign-in, when the wardrobe opens and after every skin change
  • Starter outfits pick up your current look the first time you wear one
  • Outfits deleted on the server drop out of the list

Emotes

The emote wheel editor handles several wheels and warns when the emote wheel keybind is missing or clashes with another one.

Fixes

  • Equipment resyncs when another client changes it
  • Legacy previews hide the cape when none is selected
  • Players whose session has no UUID fall back to the offline one
  • Store prices come from the backend's sale object, which replaced discount_rate. Cards showed the list price while checkout charged the sale price.

Deftu added 20 commits October 14, 2026 11:26
…ardrobe

Warn when the emote wheel keybind is missing or conflicting, keep the preview rotation across selections, jump straight to rail sections, and restore the previous section when leaving the emote wheel.
The current Mojang skin is recorded automatically after sign-in, when the wardrobe opens and after every skin change.

@Wyvest Wyvest left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the non-UI paths closely and the Compose files more quickly. The main issue is the first comment: Mojang cape errors get read as success. Nothing was compiled or run in game.

val token = accessToken() ?: throw MojangCapeException(SIGNED_OUT_MESSAGE)
val response = call(token)
if (response.status == HttpStatusCode.Unauthorized) throw MojangCapeException(SIGNED_OUT_MESSAGE)
response.body<ProfileResponse>().capes.map { it.toCape() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mojang error responses are treated as success. HttpResponseValidator skips hosts other than the API host and lets 401 through, and JSON has ignoreUnknownKeys = true. So a 400/403/429/5xx from api.minecraftservices.com (e.g. {"error":"TooManyRequestsException",...}) decodes to ProfileResponse(capes = []) and succeeds.

What happens when cape select gets rate-limited: state becomes Loaded([]), the toast says "Equipped X.", changedThisSession flips so CapeResolver returns NONE and hides the local cape, onMojangCapeChanged(null) PATCHes the active outfit's mojang_cape_id to null, and the Cosmetics tab says "You don't own any Minecraft capes." The 429 branch in failureMessage can never run.

Fix: check response.status.isSuccess() here and throw ClientRequestException/MojangCapeException otherwise, the same way MojangSkinService.ensureSkinChanged does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d863f7. request() checks the status now and throws MojangCapeException for anything that isn't a success, using the same status mapping as MojangSkinService. The 429 text comes from the exception, so I removed the ClientRequestException branch in failureMessage that could never run.

runSuspendCatching {
val file = cacheDir.resolve("$hash.png")
if (!Files.isRegularFile(file)) {
val bytes = PolyPlusClient.HTTP.get(cape.url).bodyAsBytes()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cape download has no status check, and a failure is retried every frame. The body is written to $hash.png before anything checks it decodes. texture() calls load() again on every miss, and it is reached from the cape mixin each frame (CapeResolver.decide → localCapeTexture()).

  • A 404 or HTML response gets cached as the PNG. RemoteTextures.register then throws forever, because the file exists and is never downloaded again, and every frame launches a coroutine and logs a WARN.
  • A network failure with no cached file sends the GET again every frame.

Fix: check response.status, decode before writing (or delete the file on decode failure), and keep a failed set or backoff like SkinLibrary.failed so it isn't retried per frame.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d863f7. The download checks the status and only writes $hash.png after the bytes decode. Any failure deletes the cached file, which also clears a bad file left by an older build.

I went with a 60 second backoff per hash instead of a permanent failed set, so a short network drop doesn't hide the cape until the next refresh.

.onFailure { LOGGER.warn("Could not add the current Minecraft skin to the library", it) }
.getOrNull()?.id
MojangCapeService.refreshIfNeeded()
val capeId = MojangCapeService.activeCape()?.id

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outfit can save "no cape" when Mojang capes never loaded. refreshIfNeeded() only refreshes from Idle, so after one failed fetch the state stays Unavailable, and activeCape() returns null here. adoptCurrentLook already guards with state !is Loaded, but this path doesn't.

Repro: cape fetch times out once, then the user clicks + to save their look and the outfit is stored with mojang_cape_id = null. Later, once capes load, wearing that outfit runs applyMojangCape(null) and MojangCapeService.clear() removes their real Minecraft cape.

Fix: call refresh() when the state isn't Loaded, and leave mojang_cape_id out of the body (or fail) if capes still didn't load.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d863f7. Saving the current look calls refresh() when capes aren't loaded, and if they still don't load the save fails with an error. I didn't leave mojang_cape_id out of the body, because the backend stores a missing cape as null on create, so wearing that outfit would still clear the real cape.

runSuspendCatching {
val cached = withContext(Dispatchers.IO) { readCache(skin.hash) }
val bytes = cached ?: download(skin.url).getOrThrow().also { fresh ->
withContext(Dispatchers.IO) { writeCache(skin.hash, fresh) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache is written before ImageIO.read confirms the bytes are an image, and readCache never checks them again. A 200 non-image body (captive portal, proxy) is saved as skins/<hash>.png, and that skin's thumbnail stays broken in every later session. Decode first, then writeCache, or delete the file when decoding fails.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d863f7. Skins get decoded first and written to the cache only after that works. readCache also deletes a cached file that won't decode, so it gets downloaded again.

loadedFor = owner
state = LibraryState.Loaded
activeOutfit()?.takeIf { it.blank }?.let { blank ->
PolyPlusClient.SCOPE.launch { adoptCurrentLook(blank) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every refresh launches adoptCurrentLook for a blank active outfit, and adoptCurrentLook returns without clearing blank when Mojang capes aren't Loaded (offline or cracked accounts, or Mojang down). So each wardrobe open or section switch records the skin again (recordCurrent → POST /skins) and gives up. Worth guarding with an in-flight flag, or patching just the skin when capes are unavailable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d863f7. adoptCurrentLook has an in-flight guard now, and it checks that capes are loaded before recording the skin, so the failing case doesn't POST anything.

I didn't patch only the skin. Setting skin_id makes the outfit non-blank with a null cape, which is the same problem as the save path above. Polyfrost/plus-backend#18 now adopts the current look when a blank outfit is applied, so this gets simpler on the server side too.


fun get(id: Int): SwatchArt? {
art[id]?.let { return it }
if (requested.add(id)) load(id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id goes into requested before loading and is never taken out on failure or a null build. If the first try happens before the asset is on disk, or while offline, that variant's swatch stays blank for the whole session. Remove the id from requested in onFailure and on a null build so a later version bump can retry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1d863f7. The id comes out of requested on failure and when build returns null.

if (emoteId != null) {
for (i in next.indices) if (next[i] == emoteId) next[i] = null
}
next[slot] = emoteId

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

materialized() returns stored.wheels as-is, and only the outer list is padded. A stored wheel shorter than SLOT_COUNT (a hand-edited or older emote-wheels.json) makes next[slot] throw IndexOutOfBoundsException, even though slots() still shows 8 slots. Pad the inner list too, e.g. val next = MutableList(SLOT_COUNT) { wheels[index].getOrNull(it) }, or normalize in load().

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with your suggestion in 1d863f7, the inner list gets padded to SLOT_COUNT as well.

storeVariantPicks = storeVariantPicks,
mojangBusy = MojangCapeService.busy,
modifier = Modifier.weight(1f).fillMaxWidth(),
coverFor = { item ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

store.all is a getter that flattens and concatenates every per-type list on each call. This lambda (and coverForEmote) runs once per visible card per recomposition, with a nested variant scan, so each hover or swatch/texture version bump allocates a large list per card. Suggest building a variantId -> coverAssetId map once in a remember(store.byType, store.popular, store.newest) and looking it up here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1d863f7. A variantId -> coverAssetId map is built once in remember(store.byType, store.popular, store.newest), and coverFor and coverForEmote just look it up.

There's one small difference in behaviour. If the first matching entry has no cover, a later entry that has one gets used instead of returning null.

header(HttpHeaders.Authorization, "Bearer $token")
}

private fun accessToken(): String? = runCatching {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

accessToken(), HttpRequestBuilder.authorize() and PROFILE_URL are copy-pasted in MojangSkinService (lines 209-220). Pull them into one internal helper so the token checks (blank / "0") and auth header can't drift apart.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 1d863f7. PROFILE_URL, accessToken() and authorize() live in a new internal MojangAuth object that both services use.

@saadndm

saadndm commented Oct 18, 2026

Copy link
Copy Markdown
Member

Can you bump oneconfig to 1.4.3 which includes Polyfrost/OneConfig#1190, then add back the padding in

Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
like this:

-    Row(modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+    Row(modifier = Modifier.fillMaxSize().padding(horizontal = 25.dp), horizontalArrangement = Arrangement.spacedBy(16.dp)) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status
Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants