--[[ ACCESSORY WARDROBE v1.0.7 PREVIEW FIX A small community accessory wardrobe built around the supplied experience adapter. No loadstrings. No external dependencies. No executor-only APIs required. Clipboard copy is used only when the environment already provides it. Right Alt - show / hide Close - fully unload ]] local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local UserInputService = game:GetService("UserInputService") local RunService = game:GetService("RunService") local HttpService = game:GetService("HttpService") local MarketplaceService = game:GetService("MarketplaceService") local AvatarEditorService = game:GetService("AvatarEditorService") local player = Players.LocalPlayer local playerGui = player:WaitForChild("PlayerGui") local previous = playerGui:FindFirstChild("AccessoryWardrobe") if previous then previous:Destroy() end local alive = true local connections = {} local characterConnections = {} local currentCharacter = player.Character local looks = {} local activePage = "Outfit" local requestBusy = false local queuedIds = {} local requestSerial = 0 local editingLook = nil local currentIdSet = nil local lastRequestAt = 0 -- Some experiences equip an Accessory whose SourceAssetId is 0. -- Keep the catalog ID we requested attached to that live instance instead of -- forcing the UI/save system to depend on SourceAssetId being populated. local rememberedAccessoryIds = setmetatable({}, {__mode = "k"}) local rememberedAccessoryFingerprints = {} -- Accessories do not always stay parented directly to Character. Some -- experiences reparent or detach them while their Handle continues to exist. -- Once an Accessory has been observed anywhere under this character, remember -- that live instance for the rest of its lifetime. This keeps Wardrobe aware -- of loose/reparented accessories without sweeping in other players' items. local observedCharacterAccessories = setmetatable({}, {__mode = "k"}) local productNameCache = {} local accountDescriptionIdsCache = nil local catalogResolveCache = {} local catalogResolveBusy = {} local CONFIG = { attempts = 3, timeout = 6, retryDelay = 0.65, requestGap = 0.25, maxDigits = 20, } local function connect(signal, callback) local c = signal:Connect(callback) table.insert(connections, c) return c end local function trackCharacterConnection(signal, callback) local c = signal:Connect(callback) table.insert(characterConnections, c) return c end local function clearCharacterConnections() for _, c in ipairs(characterConnections) do pcall(function() c:Disconnect() end) end table.clear(characterConnections) end local function make(className, props, parent) local object = Instance.new(className) for key, value in pairs(props or {}) do object[key] = value end object.Parent = parent return object end local function corner(parent, radius) return make("UICorner", {CornerRadius = UDim.new(0, radius or 8)}, parent) end local function trim(text) return tostring(text or ""):match("^%s*(.-)%s*$") end local function validId(text) local id = trim(text):match("^(%d+)$") if not id or #id > CONFIG.maxDigits or not id:find("[1-9]") then return nil end return id end -- Accepts raw asset IDs, Roblox catalog URLs, rbxassetid:// values, and -- mixed batches separated by spaces, newlines, commas, semicolons, or pipes. -- URL slugs/query values are intentionally ignored; only the catalog ID is used. local function parseIds(text) local ids = {} local seen = {} local function add(raw) local id = validId(raw) if id and not seen[id] then seen[id] = true table.insert(ids, id) end end local normalized = tostring(text or "") :gsub("[,;|\r\n\t]+", " ") for token in normalized:gmatch("%S+") do local lower = token:lower() -- Full/partial Roblox catalog links, including markdown-wrapped links. local id = lower:match("roblox%.com/catalog/+(%d+)") or lower:match("/catalog/+(%d+)") or lower:match("rbxassetid://(%d+)") if not id then -- Raw numbers and compact forms such as ID:123456 or id=123456. local cleaned = token :gsub("^[%[%(%{%<\"']+", "") :gsub("[%]%)%}%>\"'%.%!%?]+$", "") id = cleaned:match("^(%d+)$") or cleaned:lower():match("^id[:=](%d+)$") end add(id) end return ids end local function normalizedName(text) return tostring(text or ""):lower():gsub("[^%w]", "") end local function accessoryFingerprint(accessory) if not accessory or not accessory:IsA("Accessory") then return nil end local parts = {normalizedName(accessory.Name)} local okType, accessoryType = pcall(function() return accessory.AccessoryType end) if okType and accessoryType then table.insert(parts, tostring(accessoryType)) end local handle = accessory:FindFirstChild("Handle") if handle and handle:IsA("BasePart") then local size = handle.Size table.insert(parts, string.format("%.3f,%.3f,%.3f", size.X, size.Y, size.Z)) if handle:IsA("MeshPart") then table.insert(parts, tostring(handle.MeshId or "")) table.insert(parts, tostring(handle.TextureID or "")) else local mesh = handle:FindFirstChildOfClass("SpecialMesh") if mesh then table.insert(parts, tostring(mesh.MeshId or "")) table.insert(parts, tostring(mesh.TextureId or "")) end end local attachments = {} for _, child in ipairs(handle:GetChildren()) do if child:IsA("Attachment") then table.insert(attachments, child.Name) end end table.sort(attachments) table.insert(parts, table.concat(attachments, ",")) end return table.concat(parts, "|") end local function rememberAccessoryId(accessory, id) id = validId(id) if not accessory or not id then return nil end rememberedAccessoryIds[accessory] = id local fingerprint = accessoryFingerprint(accessory) if fingerprint and fingerprint ~= "" then rememberedAccessoryFingerprints[fingerprint] = id end return id end local function sourceId(accessory) if not accessory or not accessory:IsA("Accessory") then return nil end local remembered = rememberedAccessoryIds[accessory] if remembered then return remembered end local fingerprint = accessoryFingerprint(accessory) if fingerprint and rememberedAccessoryFingerprints[fingerprint] then return rememberAccessoryId(accessory, rememberedAccessoryFingerprints[fingerprint]) end -- Roblox normally exposes SourceAssetId, but some experience-added -- accessories leave it at 0. Check a few common metadata attributes too. local candidates = {} local ok, value = pcall(function() return accessory.SourceAssetId end) if ok then table.insert(candidates, value) end local metadataObjects = {accessory} for _, descendant in ipairs(accessory:GetDescendants()) do table.insert(metadataObjects, descendant) end for _, object in ipairs(metadataObjects) do for _, key in ipairs({"AssetId", "AccessoryId", "CatalogId", "SourceAssetId", "OriginalAssetId"}) do local okAttr, attr = pcall(function() return object:GetAttribute(key) end) if okAttr and attr ~= nil then table.insert(candidates, attr) end end -- A few community systems keep the original catalog ID in a ValueBase -- instead of an attribute. Only accept values whose object name itself -- clearly identifies the value as an asset/accessory/catalog ID. if object:IsA("IntValue") or object:IsA("NumberValue") or object:IsA("StringValue") then local key = object.Name:lower():gsub("[^%w]", "") if key == "assetid" or key == "accessoryid" or key == "catalogid" or key == "sourceassetid" or key == "originalassetid" then local okValue, stored = pcall(function() return object.Value end) if okValue then table.insert(candidates, stored) end end end end for _, candidate in ipairs(candidates) do local id = validId(candidate) if id then return rememberAccessoryId(accessory, id) end end return nil end local function collectCharacterAccessories() local list = {} local seen = {} local function add(accessory) if not accessory or not accessory:IsA("Accessory") or seen[accessory] then return end -- Parent == nil means the instance has actually been destroyed. if accessory.Parent == nil then observedCharacterAccessories[accessory] = nil return end seen[accessory] = true observedCharacterAccessories[accessory] = true table.insert(list, accessory) end if currentCharacter then -- Use descendants, not direct children. An Accessory can be tucked under -- a limb/folder/model and still belong to the local character. Physical -- attachment/weld state is deliberately irrelevant here. for _, descendant in ipairs(currentCharacter:GetDescendants()) do if descendant:IsA("Accessory") then add(descendant) end end end -- If an Accessory was previously seen under this character and Roblox later -- reparents it into Workspace/a container, keep showing it while it exists. -- This is instance-based, so unrelated accessories elsewhere are ignored. for accessory in pairs(observedCharacterAccessories) do if accessory and accessory.Parent ~= nil then add(accessory) else observedCharacterAccessories[accessory] = nil end end table.sort(list, function(a, b) local an, bn = a.Name:lower(), b.Name:lower() if an == bn then return tostring(a) < tostring(b) end return an < bn end) return list end local function equippedMap() local out = {} for _, accessory in ipairs(collectCharacterAccessories()) do local id = sourceId(accessory) if id and not out[id] then out[id] = accessory end end return out end local function requestAccessory(id) local remote = ReplicatedStorage:FindFirstChild("01_server") if not remote or not remote:IsA("RemoteEvent") then return false, "Accessory service is unavailable in this experience." end -- Keep public builds from hammering the experience remote when a full look -- is restored. The retry worker still feels instant to the person using it. local waitFor = CONFIG.requestGap - (os.clock() - lastRequestAt) if waitFor > 0 then task.wait(waitFor) end lastRequestAt = os.clock() local ok, err = pcall(function() remote:FireServer("cmd", "-gh " .. id) end) if not ok then return false, tostring(err) end return true end -- Visual language ------------------------------------------------------------- local C = { window = Color3.fromRGB(18, 19, 22), raised = Color3.fromRGB(25, 26, 30), soft = Color3.fromRGB(31, 32, 37), hover = Color3.fromRGB(37, 38, 44), line = Color3.fromRGB(49, 50, 57), text = Color3.fromRGB(244, 244, 246), sub = Color3.fromRGB(161, 162, 170), faint = Color3.fromRGB(104, 105, 113), accent = Color3.fromRGB(229, 231, 235), accentText = Color3.fromRGB(24, 25, 28), danger = Color3.fromRGB(235, 111, 120), } local gui = make("ScreenGui", { Name = "AccessoryWardrobe", ResetOnSpawn = false, IgnoreGuiInset = true, ZIndexBehavior = Enum.ZIndexBehavior.Sibling, DisplayOrder = 70, }, playerGui) local main = make("Frame", { Name = "Window", AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(500, 510), BackgroundColor3 = C.window, BorderSizePixel = 0, Active = true, }, gui) corner(main, 14) make("UIStroke", { Color = C.line, Transparency = 0.15, Thickness = 1, }, main) local scale = make("UIScale", {Scale = 1}, main) local function fitWindow() local camera = workspace.CurrentCamera if not camera then return end local viewport = camera.ViewportSize scale.Scale = math.clamp(math.min(viewport.X / 550, viewport.Y / 570, 1), 0.72, 1) end fitWindow() connect(workspace:GetPropertyChangedSignal("CurrentCamera"), fitWindow) if workspace.CurrentCamera then connect(workspace.CurrentCamera:GetPropertyChangedSignal("ViewportSize"), fitWindow) end local header = make("Frame", { Size = UDim2.new(1, 0, 0, 74), BackgroundTransparency = 1, Active = true, }, main) make("TextLabel", { Position = UDim2.fromOffset(19, 12), Size = UDim2.fromOffset(300, 21), BackgroundTransparency = 1, Text = "Accessory Wardrobe", TextColor3 = C.text, Font = Enum.Font.GothamBold, TextSize = 18, TextXAlignment = Enum.TextXAlignment.Left, }, header) make("TextLabel", { Position = UDim2.fromOffset(20, 34), Size = UDim2.fromOffset(300, 15), BackgroundTransparency = 1, Text = "Wear. Save. Restore.", TextColor3 = C.sub, Font = Enum.Font.Gotham, TextSize = 10, TextXAlignment = Enum.TextXAlignment.Left, }, header) local versionLabel = make("TextLabel", { Position = UDim2.new(1, -92, 0, 19), Size = UDim2.fromOffset(36, 18), BackgroundTransparency = 1, Text = "1.0.7", TextColor3 = C.faint, Font = Enum.Font.GothamMedium, TextSize = 8, TextXAlignment = Enum.TextXAlignment.Right, }, header) local close = make("TextButton", { AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -15, 0, 14), Size = UDim2.fromOffset(30, 30), BackgroundColor3 = C.raised, BorderSizePixel = 0, Text = "×", TextColor3 = C.sub, Font = Enum.Font.GothamMedium, TextSize = 19, AutoButtonColor = true, }, header) corner(close, 8) local divider = make("Frame", { Position = UDim2.fromOffset(0, 66), Size = UDim2.new(1, 0, 0, 1), BackgroundColor3 = C.line, BorderSizePixel = 0, }, main) local function label(parent, text, pos, size, font, textSize, color) return make("TextLabel", { Position = pos, Size = size, BackgroundTransparency = 1, Text = text, TextColor3 = color or C.text, Font = font or Enum.Font.Gotham, TextSize = textSize or 11, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Center, }, parent) end local function button(parent, text, pos, size, primary) local b = make("TextButton", { Position = pos, Size = size, BackgroundColor3 = primary and C.accent or C.raised, BorderSizePixel = 0, Text = text, TextColor3 = primary and C.accentText or C.text, Font = Enum.Font.GothamMedium, TextSize = 10, AutoButtonColor = true, }, parent) corner(b, 8) return b end local function input(parent, placeholder, pos, size) local box = make("TextBox", { Position = pos, Size = size, BackgroundColor3 = C.raised, BorderSizePixel = 0, ClearTextOnFocus = false, PlaceholderText = placeholder, PlaceholderColor3 = C.faint, Text = "", TextColor3 = C.text, Font = Enum.Font.Gotham, TextSize = 11, TextXAlignment = Enum.TextXAlignment.Left, }, parent) corner(box, 8) make("UIPadding", { PaddingLeft = UDim.new(0, 12), PaddingRight = UDim.new(0, 10), }, box) return box end -- Small toast. Technical details stay out of the product surface. -------------- local toast = make("Frame", { AnchorPoint = Vector2.new(0.5, 1), Position = UDim2.new(0.5, 0, 1, -14), Size = UDim2.fromOffset(300, 36), BackgroundColor3 = C.soft, BackgroundTransparency = 1, BorderSizePixel = 0, Visible = false, ZIndex = 20, }, main) corner(toast, 9) local toastText = make("TextLabel", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, Text = "", TextColor3 = C.text, TextTransparency = 1, Font = Enum.Font.GothamMedium, TextSize = 10, ZIndex = 21, }, toast) local toastToken = 0 local function notify(message, isError) toastToken += 1 local token = toastToken toast.Visible = true toast.BackgroundColor3 = isError and Color3.fromRGB(55, 31, 34) or C.soft toastText.TextColor3 = isError and Color3.fromRGB(250, 186, 191) or C.text toast.BackgroundTransparency = 0 toastText.TextTransparency = 0 toastText.Text = message task.delay(2.6, function() if not alive or token ~= toastToken then return end toast.Visible = false end) end -- Page switcher --------------------------------------------------------------- local tabBar = make("Frame", { Position = UDim2.fromOffset(19, 79), Size = UDim2.new(1, -38, 0, 34), BackgroundColor3 = C.raised, BorderSizePixel = 0, }, main) corner(tabBar, 9) make("UIStroke", {Color = C.line, Transparency = 0.45, Thickness = 1}, tabBar) local outfitTab = make("TextButton", { Position = UDim2.fromOffset(3, 3), Size = UDim2.new(0.5, -5, 1, -6), BackgroundColor3 = C.soft, BorderSizePixel = 0, Text = "Current outfit", TextColor3 = C.text, Font = Enum.Font.GothamMedium, TextSize = 10, }, tabBar) corner(outfitTab, 7) local looksTab = make("TextButton", { Position = UDim2.new(0.5, 2, 0, 3), Size = UDim2.new(0.5, -5, 1, -6), BackgroundTransparency = 1, BorderSizePixel = 0, Text = "Saved looks", TextColor3 = C.sub, Font = Enum.Font.GothamMedium, TextSize = 10, }, tabBar) corner(looksTab, 7) local body = make("Frame", { Position = UDim2.fromOffset(19, 125), Size = UDim2.new(1, -38, 1, -144), BackgroundTransparency = 1, }, main) local outfitPage = make("Frame", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, }, body) local looksPage = make("Frame", { Size = UDim2.fromScale(1, 1), BackgroundTransparency = 1, Visible = false, }, body) local function showPage(name) activePage = name outfitPage.Visible = name == "Outfit" looksPage.Visible = name == "Looks" outfitTab.BackgroundTransparency = name == "Outfit" and 0 or 1 outfitTab.BackgroundColor3 = C.soft outfitTab.TextColor3 = name == "Outfit" and C.text or C.sub looksTab.BackgroundTransparency = name == "Looks" and 0 or 1 looksTab.BackgroundColor3 = C.soft looksTab.TextColor3 = name == "Looks" and C.text or C.sub end -- Outfit page ----------------------------------------------------------------- local outfitHeading = make("Frame", { Position = UDim2.fromOffset(0, 0), Size = UDim2.new(1, 0, 0, 28), BackgroundTransparency = 1, }, outfitPage) local outfitTitle = label(outfitHeading, "Current outfit", UDim2.fromOffset(0, 0), UDim2.new(1, -92, 1, 0), Enum.Font.GothamBold, 13, C.text) local outfitCountPill = make("Frame", { AnchorPoint = Vector2.new(1, 0.5), Position = UDim2.new(1, 0, 0.5, 0), Size = UDim2.fromOffset(76, 24), BackgroundColor3 = C.raised, BorderSizePixel = 0, }, outfitHeading) corner(outfitCountPill, 99) local outfitCount = label(outfitCountPill, "0 items", UDim2.fromOffset(8, 0), UDim2.new(1, -16, 1, 0), Enum.Font.GothamMedium, 9, C.sub) outfitCount.TextXAlignment = Enum.TextXAlignment.Center local addShell = make("Frame", { Position = UDim2.fromOffset(0, 37), Size = UDim2.new(1, 0, 0, 42), BackgroundColor3 = C.raised, BorderSizePixel = 0, }, outfitPage) corner(addShell, 10) make("UIStroke", {Color = C.line, Transparency = 0.55, Thickness = 1}, addShell) local addBox = input(addShell, "Paste IDs or links…", UDim2.fromOffset(1, 1), UDim2.new(1, -88, 1, -2)) addBox.BackgroundTransparency = 1 local addButton = button(addShell, "Add", UDim2.new(1, -79, 0, 5), UDim2.fromOffset(73, 32), true) local listShell = make("Frame", { Position = UDim2.fromOffset(0, 89), Size = UDim2.new(1, 0, 1, -139), BackgroundColor3 = Color3.fromRGB(21, 22, 25), BorderSizePixel = 0, }, outfitPage) corner(listShell, 11) make("UIStroke", {Color = C.line, Transparency = 0.6, Thickness = 1}, listShell) local outfitScroll = make("ScrollingFrame", { Position = UDim2.fromOffset(6, 6), Size = UDim2.new(1, -12, 1, -12), BackgroundTransparency = 1, BorderSizePixel = 0, ScrollBarThickness = 3, ScrollBarImageColor3 = C.line, AutomaticCanvasSize = Enum.AutomaticSize.Y, CanvasSize = UDim2.new(), }, listShell) make("UIListLayout", { Padding = UDim.new(0, 6), SortOrder = Enum.SortOrder.LayoutOrder, }, outfitScroll) local saveCurrentButton = button(outfitPage, "Save current look", UDim2.new(0, 0, 1, -40), UDim2.new(1, 0, 0, 40), false) -- Looks page ------------------------------------------------------------------ local looksTop = make("Frame", { Position = UDim2.fromOffset(0, 0), Size = UDim2.new(1, 0, 0, 34), BackgroundTransparency = 1, }, looksPage) local looksHeading = label(looksTop, "Saved looks", UDim2.fromOffset(0, 0), UDim2.new(1, -284, 1, 0), Enum.Font.GothamBold, 13, C.text) local looksCount = label(looksTop, "0 saved", UDim2.fromOffset(94, 0), UDim2.fromOffset(64, 34), Enum.Font.Gotham, 9, C.faint) local newLookButton = button(looksTop, "Save current", UDim2.new(1, -260, 0, 1), UDim2.fromOffset(92, 32), false) local exportButton = button(looksTop, "Export", UDim2.new(1, -160, 0, 1), UDim2.fromOffset(72, 32), false) local importButton = button(looksTop, "Import", UDim2.new(1, -80, 0, 1), UDim2.fromOffset(80, 32), false) local nameEditor = make("Frame", { Position = UDim2.fromOffset(0, 44), Size = UDim2.new(1, 0, 0, 42), BackgroundColor3 = C.raised, BorderSizePixel = 0, Visible = false, }, looksPage) corner(nameEditor, 10) make("UIStroke", {Color = C.line, Transparency = 0.55, Thickness = 1}, nameEditor) local nameBox = input(nameEditor, "Name this look", UDim2.fromOffset(1, 1), UDim2.new(1, -151, 1, -2)) nameBox.BackgroundTransparency = 1 local nameSaveButton = button(nameEditor, "Save", UDim2.new(1, -143, 0, 5), UDim2.fromOffset(64, 32), true) local nameCancelButton = button(nameEditor, "Cancel", UDim2.new(1, -73, 0, 5), UDim2.fromOffset(67, 32), false) local function setNameEditorVisible(visible) nameEditor.Visible = visible nameBox.Visible = visible nameSaveButton.Visible = visible nameCancelButton.Visible = visible end setNameEditorVisible(false) local function cancelNameEdit() editingLook = nil setNameEditorVisible(false) nameBox.Text = "" nameBox:ReleaseFocus() end local looksListY = 44 local looksScroll = make("ScrollingFrame", { Position = UDim2.fromOffset(0, looksListY), Size = UDim2.new(1, 0, 1, -looksListY), BackgroundTransparency = 1, BorderSizePixel = 0, ScrollBarThickness = 3, ScrollBarImageColor3 = C.line, AutomaticCanvasSize = Enum.AutomaticSize.Y, CanvasSize = UDim2.new(), }, looksPage) make("UIListLayout", { Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder, }, looksScroll) local function syncLooksLayout() if nameEditor.Visible then looksScroll.Position = UDim2.fromOffset(0, 96) looksScroll.Size = UDim2.new(1, 0, 1, -96) else looksScroll.Position = UDim2.fromOffset(0, 44) looksScroll.Size = UDim2.new(1, 0, 1, -44) end end connect(nameEditor:GetPropertyChangedSignal("Visible"), syncLooksLayout) syncLooksLayout() -- Backup / restore ------------------------------------------------------------ local renderLooks local refreshOutfit local openAccessoryIdResolver local outfitRenderSerial = 0 local function clearPreview() end local BACKUP_PREFIX_V1 = "ACCESSORY_WARDROBE_BACKUP:1:" local BACKUP_PREFIX_V2 = "ACCESSORY_WARDROBE_BACKUP:2:" local BACKUP_PREFIX = "ACCESSORY_WARDROBE_BACKUP:3:" local backupMode = nil local backupShade = make("Frame", { Position = UDim2.fromOffset(0, 67), Size = UDim2.new(1, 0, 1, -67), BackgroundColor3 = Color3.fromRGB(8, 9, 11), BackgroundTransparency = 0.22, BorderSizePixel = 0, Visible = false, ZIndex = 30, }, main) local backupPanel = make("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(466, 270), BackgroundColor3 = C.raised, BorderSizePixel = 0, ZIndex = 31, }, backupShade) corner(backupPanel, 12) make("UIStroke", {Color = C.line, Transparency = 0.1, Thickness = 1}, backupPanel) local backupTitle = label(backupPanel, "Wardrobe backup", UDim2.fromOffset(18, 13), UDim2.new(1, -72, 0, 22), Enum.Font.GothamBold, 13, C.text) backupTitle.ZIndex = 32 local backupHint = label(backupPanel, "", UDim2.fromOffset(18, 36), UDim2.new(1, -36, 0, 28), Enum.Font.Gotham, 9, C.sub) backupHint.TextWrapped = true backupHint.TextYAlignment = Enum.TextYAlignment.Top backupHint.ZIndex = 32 local backupClose = make("TextButton", { AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -12, 0, 11), Size = UDim2.fromOffset(28, 28), BackgroundColor3 = C.soft, BorderSizePixel = 0, Text = "×", TextColor3 = C.sub, Font = Enum.Font.GothamMedium, TextSize = 18, AutoButtonColor = true, ZIndex = 33, }, backupPanel) corner(backupClose, 7) local backupBox = make("TextBox", { Position = UDim2.fromOffset(18, 70), Size = UDim2.new(1, -36, 0, 142), BackgroundColor3 = C.window, BorderSizePixel = 0, ClearTextOnFocus = false, MultiLine = true, TextWrapped = true, Text = "", PlaceholderText = "Paste a wardrobe export here...", PlaceholderColor3 = C.faint, TextColor3 = C.text, Font = Enum.Font.Code, TextSize = 9, TextXAlignment = Enum.TextXAlignment.Left, TextYAlignment = Enum.TextYAlignment.Top, ZIndex = 32, }, backupPanel) corner(backupBox, 8) make("UIPadding", { PaddingLeft = UDim.new(0, 10), PaddingRight = UDim.new(0, 10), PaddingTop = UDim.new(0, 8), PaddingBottom = UDim.new(0, 8), }, backupBox) local backupSecondary = button(backupPanel, "Close", UDim2.new(1, -176, 1, -44), UDim2.fromOffset(76, 32), false) backupSecondary.ZIndex = 32 local backupAction = button(backupPanel, "Copy", UDim2.new(1, -92, 1, -44), UDim2.fromOffset(74, 32), true) backupAction.ZIndex = 32 local function closeBackup() backupShade.Visible = false backupMode = nil backupBox:ReleaseFocus() end local function selectBackupText() backupBox:CaptureFocus() task.defer(function() if not backupShade.Visible then return end backupBox.CursorPosition = 1 backupBox.SelectionStart = #backupBox.Text + 1 end) end -- Clipboard support is optional. The backup remains fully usable with normal -- TextBox copy/paste even when an executor does not expose a clipboard API. local function copyToClipboard(text) local fn = nil -- Some environments expose clipboard functions as globals, others only -- through their shared environment. Neither path is required. pcall(function() if type(setclipboard) == "function" then fn = setclipboard elseif type(toclipboard) == "function" then fn = toclipboard end end) if not fn then local env = nil pcall(function() if type(getgenv) == "function" then env = getgenv() end end) env = env or _G if env then fn = env.setclipboard or env.toclipboard end end if type(fn) ~= "function" then return false end local ok = pcall(fn, text) return ok end local function exportPayload() local saved = {} for _, look in ipairs(looks) do local ids = {} for _, id in ipairs(look.ids or {}) do local valid = validId(id) if valid then table.insert(ids, valid) end end if #ids > 0 then table.insert(saved, { name = tostring(look.name or "Look"):sub(1, 32), ids = ids, }) end end local payload = { format = "AccessoryWardrobe", version = 3, looks = saved, settings = { activePage = activePage, window = { xScale = main.Position.X.Scale, xOffset = main.Position.X.Offset, yScale = main.Position.Y.Scale, yOffset = main.Position.Y.Offset, }, }, } return BACKUP_PREFIX .. HttpService:JSONEncode(payload) end local function decodePayload(text) text = trim(text) local prefix = nil if text:sub(1, #BACKUP_PREFIX) == BACKUP_PREFIX then prefix = BACKUP_PREFIX elseif text:sub(1, #BACKUP_PREFIX_V2) == BACKUP_PREFIX_V2 then prefix = BACKUP_PREFIX_V2 elseif text:sub(1, #BACKUP_PREFIX_V1) == BACKUP_PREFIX_V1 then prefix = BACKUP_PREFIX_V1 else return nil, "That doesn't look like a Wardrobe export." end local encoded = text:sub(#prefix + 1) local ok, payload = pcall(function() return HttpService:JSONDecode(encoded) end) if not ok or type(payload) ~= "table" then return nil, "That Wardrobe export is damaged or incomplete." end local version = tonumber(payload.version) if payload.format ~= "AccessoryWardrobe" or (version ~= 1 and version ~= 2 and version ~= 3) then return nil, "That Wardrobe export version isn't supported." end if type(payload.looks) ~= "table" then return nil, "That export doesn't contain any saved looks." end local importedLooks = {} for index, look in ipairs(payload.looks) do if index > 100 then break end if type(look) == "table" and type(look.ids) == "table" then local ids = {} local seen = {} for _, rawId in ipairs(look.ids) do if #ids >= 50 then break end local id = validId(rawId) if id and not seen[id] then seen[id] = true table.insert(ids, id) end end if #ids > 0 then local name = trim(look.name) if name == "" then name = "Imported look" end table.insert(importedLooks, { name = name:sub(1, 32), ids = ids, }) end end end if #importedLooks == 0 then return nil, "That export doesn't contain any usable looks." end return { looks = importedLooks, settings = type(payload.settings) == "table" and payload.settings or {}, } end local function lookSignature(look) return tostring(look.name or "") .. "\0" .. table.concat(look.ids or {}, ",") end local function nameInUse(name, ignoreLook) local target = trim(name):lower() for _, look in ipairs(looks) do if look ~= ignoreLook and trim(look.name):lower() == target then return true end end return false end local function uniqueLookName(base, ignoreLook) base = trim(base) if base == "" then base = "Look" end base = base:sub(1, 32) if not nameInUse(base, ignoreLook) then return base end local number = 2 while number < 1000 do local suffix = " (" .. tostring(number) .. ")" local candidate = base:sub(1, math.max(1, 32 - #suffix)) .. suffix if not nameInUse(candidate, ignoreLook) then return candidate end number += 1 end return base:sub(1, 27) .. " copy" end local function importPayload(text) local payload, err = decodePayload(text) if not payload then notify(err, true) return false end local existing = {} for _, look in ipairs(looks) do existing[lookSignature(look)] = true end local added, skipped, renamed = 0, 0, 0 for i = #payload.looks, 1, -1 do local look = payload.looks[i] local signature = lookSignature(look) if existing[signature] then skipped += 1 else local originalName = look.name look.name = uniqueLookName(look.name) if look.name ~= originalName then renamed += 1 end table.insert(looks, 1, look) existing[signature] = true existing[lookSignature(look)] = true added += 1 end end local window = payload.settings.window if type(window) == "table" then local xs, xo = tonumber(window.xScale), tonumber(window.xOffset) local ys, yo = tonumber(window.yScale), tonumber(window.yOffset) if xs and xo and ys and yo then main.Position = UDim2.new(math.clamp(xs, -1, 2), math.clamp(xo, -2000, 2000), math.clamp(ys, -1, 2), math.clamp(yo, -2000, 2000)) end end local importedPage = payload.settings.activePage if importedPage == "Outfit" or importedPage == "Looks" then showPage(importedPage) end renderLooks() closeBackup() local pieces = {} if added > 0 then table.insert(pieces, tostring(added) .. " imported") end if skipped > 0 then table.insert(pieces, tostring(skipped) .. " already saved") end if renamed > 0 then table.insert(pieces, tostring(renamed) .. " renamed") end if #pieces == 0 then table.insert(pieces, "Nothing changed") end notify(table.concat(pieces, " · ")) return true end local function openExport() if #looks == 0 then notify("Save a look before exporting.", true) return end backupMode = "export" backupTitle.Text = "Export wardrobe" backupHint.Text = "All saved looks and Wardrobe preferences are included." backupBox.PlaceholderText = "" backupBox.Text = exportPayload() backupAction.Text = "Copy" backupShade.Visible = true if copyToClipboard(backupBox.Text) then notify("Wardrobe export copied.") else selectBackupText() notify("Export ready. Copy the selected text.") end end local function openImport() backupMode = "import" backupTitle.Text = "Import wardrobe" backupHint.Text = "Paste a Wardrobe export below. Existing looks stay; missing looks are merged in." backupBox.PlaceholderText = "Paste a wardrobe export here..." backupBox.Text = "" backupAction.Text = "Import" backupShade.Visible = true backupBox:CaptureFocus() end connect(backupClose.MouseButton1Click, closeBackup) connect(backupSecondary.MouseButton1Click, closeBackup) connect(backupAction.MouseButton1Click, function() if backupMode == "export" then if copyToClipboard(backupBox.Text) then notify("Wardrobe export copied.") else selectBackupText() notify("Export selected. Press Ctrl+C to copy it.") end elseif backupMode == "import" then importPayload(backupBox.Text) end end) -- Missing catalog ID ---------------------------------------------------------- local identifyAccessory = nil local identifyShade = make("Frame", { Position = UDim2.fromOffset(0, 67), Size = UDim2.new(1, 0, 1, -67), BackgroundColor3 = Color3.fromRGB(8, 9, 11), BackgroundTransparency = 0.22, BorderSizePixel = 0, Visible = false, ZIndex = 40, }, main) local identifyPanel = make("Frame", { AnchorPoint = Vector2.new(0.5, 0.5), Position = UDim2.fromScale(0.5, 0.5), Size = UDim2.fromOffset(390, 166), BackgroundColor3 = C.raised, BorderSizePixel = 0, ZIndex = 41, }, identifyShade) corner(identifyPanel, 12) make("UIStroke", {Color = C.line, Transparency = 0.1, Thickness = 1}, identifyPanel) local identifyTitle = label(identifyPanel, "Set catalog ID", UDim2.fromOffset(18, 14), UDim2.new(1, -36, 0, 22), Enum.Font.GothamBold, 13, C.text) identifyTitle.ZIndex = 42 local identifyHint = label(identifyPanel, "Roblox didn't expose this accessory's catalog ID. Paste its ID or catalog link once and Wardrobe will remember it.", UDim2.fromOffset(18, 40), UDim2.new(1, -36, 0, 36), Enum.Font.Gotham, 9, C.sub) identifyHint.TextWrapped = true identifyHint.TextYAlignment = Enum.TextYAlignment.Top identifyHint.ZIndex = 42 local identifyBox = input(identifyPanel, "Paste ID or link…", UDim2.fromOffset(18, 85), UDim2.new(1, -152, 0, 36)) identifyBox.ZIndex = 42 local identifyCancel = button(identifyPanel, "Cancel", UDim2.new(1, -126, 0, 85), UDim2.fromOffset(68, 36), false) identifyCancel.ZIndex = 42 local identifySave = button(identifyPanel, "Save", UDim2.new(1, -52, 0, 85), UDim2.fromOffset(52, 36), true) identifySave.ZIndex = 42 local function closeAccessoryIdResolver() identifyShade.Visible = false identifyAccessory = nil identifyBox.Text = "" identifyBox:ReleaseFocus() end openAccessoryIdResolver = function(accessory) if not accessory or not accessory.Parent then return end identifyAccessory = accessory identifyTitle.Text = "Set ID for " .. accessory.Name identifyBox.Text = "" identifyShade.Visible = true identifyBox:CaptureFocus() end local function commitAccessoryId() if not identifyAccessory or not identifyAccessory.Parent then closeAccessoryIdResolver() return end local ids = parseIds(identifyBox.Text) if #ids ~= 1 then notify("Paste one accessory ID or catalog link.", true) identifyBox:CaptureFocus() return end rememberAccessoryId(identifyAccessory, ids[1]) closeAccessoryIdResolver() if refreshOutfit then refreshOutfit() end notify("Accessory ID saved.") end connect(identifyCancel.MouseButton1Click, closeAccessoryIdResolver) connect(identifySave.MouseButton1Click, commitAccessoryId) connect(identifyBox.FocusLost, function(enterPressed) if enterPressed and identifyShade.Visible then commitAccessoryId() end end) -- Render outfit --------------------------------------------------------------- local function clearRows(parent) for _, child in ipairs(parent:GetChildren()) do if child:GetAttribute("Row") then child:Destroy() end end end local function getAccessories() return collectCharacterAccessories() end local function accessorySize(accessory) local handle = accessory and accessory:FindFirstChild("Handle") if not handle or not handle:IsA("BasePart") then return nil end return handle.Size end local function sizeText(accessory) local size = accessorySize(accessory) if not size then return "Size unavailable" end return string.format("%.2f × %.2f × %.2f studs", size.X, size.Y, size.Z) end local function typeText(accessory) local ok, accessoryType = pcall(function() return accessory.AccessoryType end) if not ok or accessoryType == nil then return "Accessory" end local text = tostring(accessoryType):gsub("^Enum%.AccessoryType%.", "") if text == "Unknown" or text == "" then return "Accessory" end return text end local function locationText(accessory) if not accessory or not accessory.Parent then return "Gone" end if currentCharacter and accessory:IsDescendantOf(currentCharacter) then local handle = accessory:FindFirstChild("Handle") if handle and handle:IsA("BasePart") then -- Being under Character is enough to belong to the outfit. A weld is -- not required; this label only helps explain loose items visually. local physicallyLinked = false for _, joint in ipairs(handle:GetChildren()) do if joint:IsA("Weld") or joint:IsA("WeldConstraint") or joint:IsA("Motor6D") then physicallyLinked = true break end end return physicallyLinked and "On character" or "Loose" end return "On character" end return "Detached" end local function buildAccessoryPreview(parent, accessory) local shell = make("Frame", { Position = UDim2.fromOffset(10, 10), Size = UDim2.fromOffset(48, 48), BackgroundColor3 = C.soft, BorderSizePixel = 0, }, parent) corner(shell, 8) make("UIStroke", {Color = C.line, Transparency = 0.55, Thickness = 1}, shell) local viewport = make("ViewportFrame", { Position = UDim2.fromOffset(1, 1), Size = UDim2.new(1, -2, 1, -2), BackgroundTransparency = 1, BorderSizePixel = 0, LightColor = Color3.fromRGB(255, 255, 255), LightDirection = Vector3.new(-1, -1, -1), Ambient = Color3.fromRGB(185, 185, 185), }, shell) local worldModel = make("WorldModel", {}, viewport) local camera = make("Camera", { FieldOfView = 35, }, viewport) viewport.CurrentCamera = camera local okay, clone = pcall(function() return accessory:Clone() end) if not okay or not clone then local fallback = label(shell, "?", UDim2.fromOffset(0, 0), UDim2.fromScale(1, 1), Enum.Font.GothamMedium, 16, C.sub) fallback.TextXAlignment = Enum.TextXAlignment.Center return shell end local wrapper = make("Model", {Name = "PreviewItem"}, worldModel) clone.Parent = wrapper local partCount = 0 for _, descendant in ipairs(wrapper:GetDescendants()) do if descendant:IsA("BasePart") then partCount += 1 descendant.Anchored = true descendant.CanCollide = false descendant.CanTouch = false descendant.CanQuery = false descendant.Massless = true elseif descendant:IsA("Script") or descendant:IsA("LocalScript") then descendant:Destroy() end end if partCount == 0 then wrapper:Destroy() local fallback = label(shell, "?", UDim2.fromOffset(0, 0), UDim2.fromScale(1, 1), Enum.Font.GothamMedium, 16, C.sub) fallback.TextXAlignment = Enum.TextXAlignment.Center return shell end -- Accessories cloned from the live character retain their world-space -- CFrames. Recenter every preview part around the origin before framing it. local okBox, boxCFrame, boxSize = pcall(function() return wrapper:GetBoundingBox() end) if okBox then local shift = CFrame.new(-boxCFrame.Position) for _, descendant in ipairs(wrapper:GetDescendants()) do if descendant:IsA("BasePart") then descendant.CFrame = shift * descendant.CFrame end end end okBox, boxCFrame, boxSize = pcall(function() return wrapper:GetBoundingBox() end) if not okBox then boxCFrame = CFrame.new() boxSize = Vector3.new(2, 2, 2) end local maxSize = math.max(boxSize.X, boxSize.Y, boxSize.Z, 0.5) local distance = math.max(2.2, maxSize * 1.9) local focus = boxCFrame.Position -- Slightly elevated three-quarter view makes hats, back items, shoulder -- pieces, and flatter accessories readable in the same tiny preview box. local cameraPos = focus + Vector3.new(distance * 0.72, distance * 0.32, distance) camera.CFrame = CFrame.lookAt(cameraPos, focus) return shell end local function removeAccessory(accessory, buttonObject) if not accessory or not accessory.Parent then return end local displayName = accessory.Name if buttonObject then buttonObject.Active = false buttonObject.AutoButtonColor = false buttonObject.Text = "·" buttonObject.TextColor3 = C.faint end local ok = pcall(function() accessory:Destroy() end) if ok and not accessory.Parent then notify("Removed " .. displayName .. ".") task.defer(refreshOutfit) else if buttonObject and buttonObject.Parent then buttonObject.Active = true buttonObject.AutoButtonColor = true buttonObject.Text = "×" buttonObject.TextColor3 = C.danger end notify("Couldn't remove that accessory.", true) end end refreshOutfit = function() if not alive then return end -- Refreshes can arrive almost simultaneously from ChildAdded/ChildRemoved, -- the equip worker, and the end of a batch. ID recovery may yield while it -- talks to Roblox services. Without a generation guard, two overlapping -- refreshes can both resume and draw the same live Accessory twice. outfitRenderSerial += 1 local renderSerial = outfitRenderSerial -- Resolve before touching the visible rows. If another refresh starts while -- this yields, this render is stale and must not draw anything. if currentIdSet then currentIdSet() end if not alive or renderSerial ~= outfitRenderSerial then return end clearRows(outfitScroll) local accessories = getAccessories() outfitCount.Text = tostring(#accessories) .. (#accessories == 1 and " item" or " items") for index, accessory in ipairs(accessories) do local row = make("Frame", { Size = UDim2.new(1, 0, 0, 68), BackgroundColor3 = C.raised, BorderSizePixel = 0, LayoutOrder = index, }, outfitScroll) row:SetAttribute("Row", true) corner(row, 8) buildAccessoryPreview(row, accessory) local dot = make("Frame", { Position = UDim2.fromOffset(64, 12), Size = UDim2.fromOffset(9, 9), BackgroundColor3 = Color3.fromRGB(142, 144, 153), BorderSizePixel = 0, }, row) corner(dot, 99) local removeButton = make("TextButton", { AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -10, 0, 10), Size = UDim2.fromOffset(28, 28), BackgroundColor3 = C.soft, BorderSizePixel = 0, Text = "×", TextColor3 = C.danger, Font = Enum.Font.GothamMedium, TextSize = 16, AutoButtonColor = true, }, row) corner(removeButton, 7) local name = label(row, accessory.Name, UDim2.fromOffset(82, 9), UDim2.new(1, -124, 0, 18), Enum.Font.GothamMedium, 10, C.text) name.TextTruncate = Enum.TextTruncate.AtEnd local id = sourceId(accessory) local where = locationText(accessory) if where == "On character" then dot.BackgroundColor3 = Color3.fromRGB(128, 190, 153) elseif where == "Loose" then dot.BackgroundColor3 = Color3.fromRGB(208, 178, 112) elseif where == "Detached" then dot.BackgroundColor3 = C.danger end local identityText = id and ("ID " .. id .. " · " .. typeText(accessory) .. " · " .. where) or (typeText(accessory) .. " · " .. where .. " · ID unavailable") local identityWidth = id and -48 or -104 local identity = label(row, identityText, UDim2.fromOffset(82, 29), UDim2.new(1, identityWidth - 48, 0, 13), Enum.Font.Gotham, 8, C.faint) identity.TextTruncate = Enum.TextTruncate.AtEnd if not id then local setIdButton = make("TextButton", { AnchorPoint = Vector2.new(1, 0), Position = UDim2.new(1, -46, 0, 29), Size = UDim2.fromOffset(56, 18), BackgroundTransparency = 1, BorderSizePixel = 0, Text = "Set ID", TextColor3 = C.accent, Font = Enum.Font.GothamMedium, TextSize = 8, AutoButtonColor = true, }, row) setIdButton.MouseButton1Click:Connect(function() if openAccessoryIdResolver then openAccessoryIdResolver(accessory) end end) end local dimensions = label(row, sizeText(accessory), UDim2.fromOffset(82, 48), UDim2.new(1, -120, 0, 14), Enum.Font.Gotham, 8, C.sub) dimensions.TextTruncate = Enum.TextTruncate.AtEnd removeButton.MouseButton1Click:Connect(function() removeAccessory(accessory, removeButton) end) end if #accessories == 0 then local empty = make("Frame", { Size = UDim2.new(1, 0, 0, 80), BackgroundTransparency = 1, LayoutOrder = 1, }, outfitScroll) empty:SetAttribute("Row", true) local a = label(empty, "Nothing here yet", UDim2.fromOffset(0, 17), UDim2.new(1, 0, 0, 18), Enum.Font.GothamMedium, 10, C.sub) a.TextXAlignment = Enum.TextXAlignment.Center local b = label(empty, "Add an accessory above to start a look.", UDim2.fromOffset(0, 38), UDim2.new(1, 0, 0, 16), Enum.Font.Gotham, 8, C.faint) b.TextXAlignment = Enum.TextXAlignment.Center end if renderSerial ~= outfitRenderSerial then return end if renderLooks then task.defer(renderLooks) end end -- Hidden request worker -------------------------------------------------------- local function snapshot() local map = {} for _, accessory in ipairs(collectCharacterAccessories()) do map[accessory] = true end return map end local function waitForAccessory(id, before, timeout) local found = nil local connection if currentCharacter then connection = currentCharacter.DescendantAdded:Connect(function(descendant) if found or not descendant:IsA("Accessory") or before[descendant] then return end observedCharacterAccessories[descendant] = true task.defer(function() task.wait(0.12) if found or not descendant.Parent then return end local sid = sourceId(descendant) if sid == id or sid == nil then found = descendant end end) end) end local deadline = os.clock() + timeout while alive and os.clock() < deadline do local already = equippedMap()[id] if already then found = already break end -- Also accept a newly observed Accessory even when it was inserted under -- a nested character container and still has no recoverable ID yet. for _, accessory in ipairs(collectCharacterAccessories()) do if not before[accessory] then local sid = sourceId(accessory) if sid == id or sid == nil then found = accessory break end end end if found then break end task.wait(0.06) end if connection then connection:Disconnect() end return found end local function setAddBusy(busy, count) requestBusy = busy addButton.Active = not busy addBox.TextEditable = not busy if busy then addButton.Text = count and count > 1 and ("Adding " .. tostring(count)) or "Adding" addButton.BackgroundColor3 = C.soft addButton.TextColor3 = C.sub else local parsedCount = #parseIds(addBox.Text) addButton.Text = parsedCount > 1 and ("Add " .. tostring(parsedCount)) or "Add" addButton.BackgroundColor3 = C.accent addButton.TextColor3 = C.accentText end end local function addAccessories(ids) if requestBusy or #ids == 0 then return end local present = currentIdSet and currentIdSet() or equippedMap() local work = {} for _, id in ipairs(ids) do if not present[id] then table.insert(work, id) end end if #work == 0 then notify("Those accessories are already in your outfit.") return end setAddBusy(true, #work) requestSerial += 1 local serial = requestSerial task.spawn(function() local added = 0 local failed = {} for _, id in ipairs(work) do if not alive or serial ~= requestSerial then break end local success = false for attempt = 1, CONFIG.attempts do if not alive or serial ~= requestSerial then break end if equippedMap()[id] then success = true break end local before = snapshot() local sent, err = requestAccessory(id) if not sent then if tostring(err):find("unavailable", 1, true) then table.insert(failed, id) notify(err, true) break end else local accessory = waitForAccessory(id, before, CONFIG.timeout) if accessory then -- Preserve the exact requested catalog ID even when Roblox leaves -- SourceAssetId at 0 on the newly equipped Accessory. rememberAccessoryId(accessory, id) success = true break end end if attempt < CONFIG.attempts then task.wait(CONFIG.retryDelay) end end if success then added += 1 refreshOutfit() else if not table.find(failed, id) then table.insert(failed, id) end end end if alive and serial == requestSerial then addBox.Text = "" setAddBusy(false) refreshOutfit() if #failed == 0 then notify(added == 1 and "Accessory added." or (tostring(added) .. " accessories added.")) elseif added > 0 then notify(tostring(added) .. " added, " .. tostring(#failed) .. " couldn't be added.", true) else notify("Couldn't add that accessory. Try again.", true) end end end) end -- Looks ----------------------------------------------------------------------- local RIGID_DESCRIPTION_PROPERTIES = { {property = "HatAccessory", type = Enum.AccessoryType.Hat}, {property = "HairAccessory", type = Enum.AccessoryType.Hair}, {property = "FaceAccessory", type = Enum.AccessoryType.Face}, {property = "NeckAccessory", type = Enum.AccessoryType.Neck}, {property = "ShouldersAccessory", type = Enum.AccessoryType.Shoulder}, {property = "FrontAccessory", type = Enum.AccessoryType.Front}, {property = "BackAccessory", type = Enum.AccessoryType.Back}, {property = "WaistAccessory", type = Enum.AccessoryType.Waist}, } local function descriptionEntries(description) local entries = {} local seen = {} if not description then return entries end local function add(value, accessoryType) local id = validId(value) if not id or seen[id] then return end seen[id] = true table.insert(entries, { id = id, accessoryType = accessoryType, }) end -- GetAccessories(true) is the best source because it includes both rigid -- and layered accessories and keeps the accessory type beside the asset ID. local okAccessories, accessories = pcall(function() return description:GetAccessories(true) end) if okAccessories and type(accessories) == "table" then for _, info in ipairs(accessories) do if type(info) == "table" then add(info.AssetId, info.AccessoryType) end end end -- Classic accessory string properties are a fallback for experiences where -- GetAccessories() returns an incomplete table. for _, slot in ipairs(RIGID_DESCRIPTION_PROPERTIES) do local okValue, value = pcall(function() return description[slot.property] end) if okValue and type(value) == "string" then for number in value:gmatch("%d+") do add(number, slot.type) end end end return entries end local function idsFromDescription(description) local ids = {} for _, entry in ipairs(descriptionEntries(description)) do table.insert(ids, entry.id) end return ids end local function descriptionCandidates() local entries = {} local seen = {} local function addMany(values) for _, entry in ipairs(values or {}) do local id = validId(entry.id) if id and not seen[id] then seen[id] = true table.insert(entries, { id = id, accessoryType = entry.accessoryType, }) end end end if currentCharacter then local humanoid = currentCharacter:FindFirstChildOfClass("Humanoid") if humanoid then local okApplied, applied = pcall(function() return humanoid:GetAppliedDescription() end) if okApplied and applied then addMany(descriptionEntries(applied)) pcall(function() applied:Destroy() end) end end end -- Keep the platform avatar as another source. This is especially useful -- for accessories that were already present before Wardrobe started. if accountDescriptionIdsCache == nil then accountDescriptionIdsCache = {} local okAvatar, avatarDescription = pcall(function() return Players:GetHumanoidDescriptionFromUserId(player.UserId) end) if okAvatar and avatarDescription then accountDescriptionIdsCache = descriptionEntries(avatarDescription) pcall(function() avatarDescription:Destroy() end) end end addMany(accountDescriptionIdsCache) return entries end local function productName(id) id = validId(id) if not id then return nil end if productNameCache[id] ~= nil then return productNameCache[id] or nil end local ok, info = pcall(function() return MarketplaceService:GetProductInfoAsync(tonumber(id), Enum.InfoType.Asset) end) local name = ok and type(info) == "table" and tostring(info.Name or "") or "" productNameCache[id] = name ~= "" and name or false return productNameCache[id] or nil end local function sameAccessoryType(a, b) if not a or not b then return false end return tostring(a) == tostring(b) end local function accessoryTypeOf(accessory) local ok, value = pcall(function() return accessory.AccessoryType end) return ok and value or nil end local function avatarTypeMatchesAccessory(assetTypeName, accessory) if not assetTypeName or not accessory then return false end local okEnum, avatarType = pcall(function() return Enum.AvatarAssetType[tostring(assetTypeName)] end) if not okEnum or not avatarType then return false end local okMapped, mapped = pcall(function() return AvatarEditorService:GetAccessoryType(avatarType) end) if not okMapped or not mapped then return false end local liveType = accessoryTypeOf(accessory) if not liveType or liveType == Enum.AccessoryType.Unknown then return true end return sameAccessoryType(mapped, liveType) end local function searchCatalogIdForAccessory(accessory) if not accessory or not accessory:IsA("Accessory") then return nil end local fingerprint = accessoryFingerprint(accessory) local cacheKey = fingerprint or normalizedName(accessory.Name) if cacheKey ~= "" and catalogResolveCache[cacheKey] ~= nil then return catalogResolveCache[cacheKey] or nil end -- Generic runtime names are not safe search keys. Let the manual Set ID -- fallback handle these rather than guessing the wrong catalog item. local query = trim(accessory.Name) local normalizedQuery = normalizedName(query) if normalizedQuery == "" or normalizedQuery == "accessory" or normalizedQuery == "hat" then if cacheKey ~= "" then catalogResolveCache[cacheKey] = false end return nil end if catalogResolveBusy[cacheKey] then return nil end catalogResolveBusy[cacheKey] = true local resolved = nil local okSearch, pages = pcall(function() local params = CatalogSearchParams.new() params.SearchKeyword = query params.IncludeOffSale = true params.Limit = 30 return AvatarEditorService:SearchCatalogAsync(params) end) if okSearch and pages then local okPage, page = pcall(function() return pages:GetCurrentPage() end) if okPage and type(page) == "table" then local exact = {} for _, item in ipairs(page) do if type(item) == "table" and tostring(item.ItemType or "") == "Asset" and normalizedName(item.Name) == normalizedQuery and avatarTypeMatchesAccessory(item.AssetType, accessory) then local id = validId(item.Id) if id then table.insert(exact, id) end end end -- Only auto-resolve when the catalog result is unambiguous. If two -- different assets share the exact same display name/type, guessing -- would poison saved looks, so leave Set ID available instead. if #exact == 1 then resolved = exact[1] end end end catalogResolveBusy[cacheKey] = nil if cacheKey ~= "" then catalogResolveCache[cacheKey] = resolved or false end if resolved then return rememberAccessoryId(accessory, resolved) end return nil end local function resolveDescriptionIds(accessories, allowCatalogSearch) local candidates = descriptionCandidates() local used = {} local unresolved = {} for _, accessory in ipairs(accessories) do local id = sourceId(accessory) if id then used[id] = true else table.insert(unresolved, accessory) end end local available = {} for _, entry in ipairs(candidates) do if not used[entry.id] then table.insert(available, entry) end end -- First match exact catalog names, but only among candidates whose -- HumanoidDescription accessory type agrees with the live accessory. for i = #unresolved, 1, -1 do local accessory = unresolved[i] local target = normalizedName(accessory.Name) local liveType = accessoryTypeOf(accessory) local matchedIndex = nil for index, entry in ipairs(available) do local typeOkay = not liveType or not entry.accessoryType or sameAccessoryType(liveType, entry.accessoryType) if typeOkay then local catalogName = normalizedName(productName(entry.id)) if catalogName ~= "" and catalogName == target then matchedIndex = index break end end end if matchedIndex then local entry = table.remove(available, matchedIndex) rememberAccessoryId(accessory, entry.id) used[entry.id] = true table.remove(unresolved, i) end end -- Then resolve unambiguous type buckets. This fixes ordinary avatar hats, -- hair, back accessories, etc. even when the runtime Instance name differs -- from the catalog display name. for i = #unresolved, 1, -1 do local accessory = unresolved[i] local liveType = accessoryTypeOf(accessory) if liveType then local candidateIndexes = {} for index, entry in ipairs(available) do if entry.accessoryType and sameAccessoryType(liveType, entry.accessoryType) then table.insert(candidateIndexes, index) end end if #candidateIndexes == 1 then local entry = table.remove(available, candidateIndexes[1]) rememberAccessoryId(accessory, entry.id) used[entry.id] = true table.remove(unresolved, i) end end end -- If the remaining description set exactly matches the remaining live set, -- assign in character order. At this point name and type matching have -- already consumed every deterministic pair. if #unresolved > 0 and #available == #unresolved then for index, accessory in ipairs(unresolved) do rememberAccessoryId(accessory, available[index].id) end table.clear(unresolved) end -- Accessories equipped by another giver/script may never enter the applied -- HumanoidDescription. Recover those directly from Roblox's catalog using -- exact display-name + AccessoryType matching. This is intentionally done -- last and only accepts an unambiguous result. if allowCatalogSearch and #unresolved > 0 then for i = #unresolved, 1, -1 do local accessory = unresolved[i] if searchCatalogIdForAccessory(accessory) then table.remove(unresolved, i) end end end return unresolved end local function currentIds() local accessories = getAccessories() if #accessories == 0 then return {}, {} end local unresolved = resolveDescriptionIds(accessories, true) local ids = {} local seen = {} for _, accessory in ipairs(accessories) do local id = sourceId(accessory) if id and not seen[id] then seen[id] = true table.insert(ids, id) end end return ids, unresolved end currentIdSet = function() local set = {} local ids = currentIds() for _, id in ipairs(ids) do set[id] = true end return set end local function missingIdsForLook(look) local present = currentIdSet() local missing = {} local worn = 0 for _, id in ipairs(look.ids or {}) do if present[id] then worn += 1 else table.insert(missing, id) end end return missing, worn end local function beginSaveLook() local ids, unresolved = currentIds() if #ids == 0 and #unresolved > 0 then notify("Set the catalog ID on the accessory first.", true) if openAccessoryIdResolver then openAccessoryIdResolver(unresolved[1]) end return elseif #unresolved > 0 then notify(tostring(#unresolved) .. " accessory ID" .. (#unresolved == 1 and " is" or "s are") .. " still missing.", true) if openAccessoryIdResolver then openAccessoryIdResolver(unresolved[1]) end return elseif #ids == 0 then notify("There's nothing to save yet.", true) return end editingLook = nil setNameEditorVisible(true) nameBox.PlaceholderText = "Name this look" nameBox.Text = "" nameBox:CaptureFocus() end local function beginRenameLook(look) if not look then return end editingLook = look setNameEditorVisible(true) nameBox.PlaceholderText = "Rename this look" nameBox.Text = tostring(look.name or "") nameBox:CaptureFocus() task.defer(function() if nameBox.Visible then nameBox.CursorPosition = #nameBox.Text + 1 nameBox.SelectionStart = 1 end end) end local function commitLook() local name = trim(nameBox.Text) if name == "" then notify("Give this look a name first.", true) nameBox:CaptureFocus() return end if editingLook then local look = editingLook if not table.find(looks, look) then editingLook = nil setNameEditorVisible(false) return end look.name = uniqueLookName(name, look) editingLook = nil setNameEditorVisible(false) nameBox.Text = "" renderLooks() notify("Renamed to “" .. look.name .. "”.") return end local ids, unresolved = currentIds() if #unresolved > 0 then setNameEditorVisible(false) notify("One accessory still needs its catalog ID.", true) if openAccessoryIdResolver then openAccessoryIdResolver(unresolved[1]) end return elseif #ids == 0 then setNameEditorVisible(false) notify("There's nothing to save yet.", true) return end local finalName = uniqueLookName(name) table.insert(looks, 1, { name = finalName, ids = ids, }) setNameEditorVisible(false) nameBox.Text = "" renderLooks() notify("Saved “" .. finalName .. "”.") end renderLooks = function() clearRows(looksScroll) looksCount.Text = tostring(#looks) .. (#looks == 1 and " saved" or " saved") local present = currentIdSet and currentIdSet() or {} for index, look in ipairs(looks) do local total = #(look.ids or {}) local worn = 0 local missing = {} for _, id in ipairs(look.ids or {}) do if present[id] then worn += 1 else table.insert(missing, id) end end local row = make("Frame", { Size = UDim2.new(1, 0, 0, 68), BackgroundColor3 = C.raised, BorderSizePixel = 0, LayoutOrder = index, }, looksScroll) row:SetAttribute("Row", true) corner(row, 9) local n = label(row, look.name, UDim2.fromOffset(14, 8), UDim2.new(1, -232, 0, 20), Enum.Font.GothamMedium, 11, C.text) n.TextTruncate = Enum.TextTruncate.AtEnd local countText = tostring(total) .. (total == 1 and " accessory" or " accessories") local stateText if total > 0 and worn == total then stateText = countText .. " · wearing" elseif worn > 0 then stateText = countText .. " · " .. tostring(worn) .. "/" .. tostring(total) .. " worn" else stateText = countText end label(row, stateText, UDim2.fromOffset(14, 30), UDim2.new(1, -232, 0, 16), Enum.Font.Gotham, 8, worn == total and total > 0 and C.sub or C.faint) local wearText = (#missing == 0 and total > 0) and "Wearing" or (worn > 0 and "Restore" or "Wear") local wear = button(row, wearText, UDim2.new(1, -211, 0, 17), UDim2.fromOffset(76, 34), #missing > 0) local rename = button(row, "Rename", UDim2.new(1, -128, 0, 17), UDim2.fromOffset(62, 34), false) local remove = button(row, "Delete", UDim2.new(1, -59, 0, 17), UDim2.fromOffset(51, 34), false) if #missing == 0 then wear.Active = false wear.AutoButtonColor = false wear.BackgroundColor3 = C.soft wear.TextColor3 = C.faint else wear.MouseButton1Click:Connect(function() local nowMissing = missingIdsForLook(look) if #nowMissing == 0 then renderLooks() return end showPage("Outfit") addAccessories(nowMissing) end) end rename.MouseButton1Click:Connect(function() beginRenameLook(look) end) remove.MouseButton1Click:Connect(function() local found = table.find(looks, look) if found then table.remove(looks, found) end if editingLook == look then editingLook = nil setNameEditorVisible(false) nameBox.Text = "" end renderLooks() end) end if #looks == 0 then local empty = make("Frame", { Size = UDim2.new(1, 0, 0, 115), BackgroundTransparency = 1, LayoutOrder = 1, }, looksScroll) empty:SetAttribute("Row", true) local a = label(empty, "No saved looks", UDim2.fromOffset(0, 31), UDim2.new(1, 0, 0, 20), Enum.Font.GothamMedium, 11, C.sub) a.TextXAlignment = Enum.TextXAlignment.Center local b = label(empty, "Save the outfit you're wearing and reuse it later.", UDim2.fromOffset(0, 56), UDim2.new(1, 0, 0, 18), Enum.Font.Gotham, 9, C.faint) b.TextXAlignment = Enum.TextXAlignment.Center end end -- Character ------------------------------------------------------------------- local function bindCharacter(character) clearCharacterConnections() currentCharacter = character requestSerial += 1 setAddBusy(false) -- A respawn starts a new ownership scope. Do not carry loose Accessory -- instances from the previous Character into the new wardrobe. for accessory in pairs(observedCharacterAccessories) do observedCharacterAccessories[accessory] = nil end if character then -- Seed every Accessory anywhere in the hierarchy immediately. for _, descendant in ipairs(character:GetDescendants()) do if descendant:IsA("Accessory") then observedCharacterAccessories[descendant] = true end end trackCharacterConnection(character.DescendantAdded, function(descendant) if descendant:IsA("Accessory") then observedCharacterAccessories[descendant] = true task.defer(refreshOutfit) end end) trackCharacterConnection(character.DescendantRemoving, function(descendant) if descendant:IsA("Accessory") then -- Do not forget it here. Reparenting out of Character fires -- DescendantRemoving before the Accessory receives its new -- parent. collectCharacterAccessories() will keep the observed -- live instance if it survives, or prune it once destroyed. task.defer(refreshOutfit) end end) end task.defer(refreshOutfit) end bindCharacter(player.Character) connect(player.CharacterAdded, function(character) task.wait(0.15) bindCharacter(character) end) -- Reconcile the full hierarchy periodically as a backstop for experiences that -- rapidly reparent accessories through nested containers. This does not redraw -- every tick; it only refreshes when the live Accessory instance set changes. local reconcileElapsed = 0 local lastAccessoryMembership = {} connect(RunService.Heartbeat, function(dt) reconcileElapsed += dt if reconcileElapsed < 0.6 or not alive then return end reconcileElapsed = 0 local current = {} local changed = false for _, accessory in ipairs(collectCharacterAccessories()) do current[accessory] = true if not lastAccessoryMembership[accessory] then changed = true end end if not changed then for accessory in pairs(lastAccessoryMembership) do if not current[accessory] then changed = true break end end end lastAccessoryMembership = current if changed then task.defer(refreshOutfit) end end) -- Interactions ---------------------------------------------------------------- connect(outfitTab.MouseButton1Click, function() showPage("Outfit") end) connect(looksTab.MouseButton1Click, function() showPage("Looks") end) connect(addBox:GetPropertyChangedSignal("Text"), function() if requestBusy then return end local count = #parseIds(addBox.Text) addButton.Text = count > 1 and ("Add " .. tostring(count)) or "Add" end) connect(addButton.MouseButton1Click, function() if requestBusy then return end local ids = parseIds(addBox.Text) if #ids == 0 then notify("Paste an accessory ID or Roblox catalog link.", true) return end addAccessories(ids) end) connect(addBox.FocusLost, function(enterPressed) if enterPressed and not requestBusy then local ids = parseIds(addBox.Text) if #ids > 0 then addAccessories(ids) end end end) connect(saveCurrentButton.MouseButton1Click, function() showPage("Looks") beginSaveLook() end) connect(newLookButton.MouseButton1Click, beginSaveLook) connect(exportButton.MouseButton1Click, openExport) connect(importButton.MouseButton1Click, openImport) connect(nameSaveButton.MouseButton1Click, function() if nameBox.Visible then commitLook() end end) connect(nameCancelButton.MouseButton1Click, cancelNameEdit) connect(nameBox.FocusLost, function(enterPressed) if enterPressed and nameBox.Visible then commitLook() end end) connect(close.MouseButton1Click, function() gui:Destroy() end) -- Drag window ----------------------------------------------------------------- local draggingWindow = false local dragStart = nil local windowStart = nil connect(header.InputBegan, function(inputObject) if inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch then draggingWindow = true dragStart = inputObject.Position windowStart = main.Position end end) connect(UserInputService.InputChanged, function(inputObject) if not draggingWindow then return end if inputObject.UserInputType ~= Enum.UserInputType.MouseMovement and inputObject.UserInputType ~= Enum.UserInputType.Touch then return end local delta = inputObject.Position - dragStart main.Position = UDim2.new( windowStart.X.Scale, windowStart.X.Offset + delta.X, windowStart.Y.Scale, windowStart.Y.Offset + delta.Y ) end) connect(UserInputService.InputEnded, function(inputObject) if inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch then draggingWindow = false end end) connect(UserInputService.InputBegan, function(inputObject, processed) if inputObject.KeyCode == Enum.KeyCode.Escape and identifyShade.Visible then closeAccessoryIdResolver() return end if inputObject.KeyCode == Enum.KeyCode.Escape and backupShade.Visible then closeBackup() return end if inputObject.KeyCode == Enum.KeyCode.Escape and nameBox.Visible then cancelNameEdit() return end if processed or UserInputService:GetFocusedTextBox() then return end if inputObject.KeyCode == Enum.KeyCode.RightAlt then main.Visible = not main.Visible end end) connect(gui.Destroying, function() alive = false requestSerial += 1 clearCharacterConnections() for _, c in ipairs(connections) do pcall(function() c:Disconnect() end) end table.clear(connections) clearPreview() end) showPage("Outfit") renderLooks() refreshOutfit()