Repository navigation
Releases: ing-gom/Sts2SkinManager
Release list
v0.27.1 — drag the menu anywhere + upward flip + ModKit settings bridge
v0.27.1 — drag the menu anywhere + upward flip + ModKit settings bridge
Added — drag the whole menu anywhere on the screen (⋮⋮ grip)
- The overlay could only sit in one of two corners, and the anchor dropdown only swaps between them — which is no help when another mod parks its own menu bar in both. The collapsed header now starts with a ⋮⋮ grip: drag it to move the entire overlay (dropdown row + hover preview + tab panel) as one unit; double-click resets it to the anchored corner. Tooltip localized in all 16 languages (
overlay_drag_tooltip). - The drag is a screen-space delta on top of the anchor, not a new positioning mode: every group is still anchored to its corner (
Left/Rightfrom the settings dropdown) and the same(dx, dy)is added to both horizontal offsets, which translates a right-anchored control — whose base offsets are negative — by exactly the same pixels as a left-anchored one. So one offset serves both anchors and the existing layout code is untouched apart from the+ _dragOffsetterm. - Drag continues after the cursor slides off the grip.
_GuiInputonly fires while the pointer is inside the control, so it just starts/stops the drag; the motion itself is carried in_Input, which runs ahead of GUI routing. Without that split a fast drag "slips off" the handle and the menu stops following the mouse. - Persisted to its own file (
overlay_layout.json, next toskin_choices.json) rather than intoskin_choices.json. That file is watched byChoicesFileWatcherand any write to it pops the restart/countdown modal — moving a menu must not look like a skin change. Saved on drag end only, not per motion frame. - The offset is clamped so the menu can never be lost off-screen: at least a 180 px horizontal sliver of the header stays visible, and vertically whichever element is lowest is kept on-screen. The clamp is re-applied once on attach, because a position saved at one resolution can land off-screen at another.
Added — the panel opens upward when the menu is dragged into the bottom half
- Dragging the header below the screen's vertical midpoint would have made the expanded body grow straight off the bottom edge. Past that midpoint the accordion flips: the body is moved to be the first child and the group switches to bottom-anchored with
GrowVertical.Begin, so it grows up from the header — and the dropdown row mirrors to just below the header. The header itself never moves, so the flip is invisible while collapsed. - Child reordering (
MoveChild) is the flip's only structural side effect and lives in one place (ApplyAccordionOrder), called both on the flip transition and once after the header row and body are built — the early call fromPositionAccordionshort-circuits while those children don't exist yet.
Changed — settings registration moved to the shared ModKit bridge
ModConfigBridgewas ~110 lines of hand-rolled reflection againstModConfig.ModConfigApi. It now delegates toSts2.ModKit.Config.ModConfigBridge, which registers through either RitsuLib or ModConfig (RitsuLib preferred) and no-ops with defaults when neither is installed — so the mod still has zero hard dependency, and users with only RitsuLib now get the overlay-position dropdown too. The local class name is kept soMainFile's call site is unchanged.- The build moved to
Sts2.ModKit.props(shared references / publicizer / mods-folder copy), and anAssemblyResolverBootstrapmodule initializer registers theAssemblyLoadContext.Resolvinghook the ModKit consumers need. Build note: the project now imports..\Sts2.ModKit\build\Sts2.ModKit.props, so building from a standalone clone of this repository requires the siblingSts2.ModKitdirectory.
Verification
- Release build clean and shipped to the Steam Workshop as 0.27.1 (item 3747513223). Both the drag/flip geometry and the clamp are analytic — computed from the anchors' base offsets and the viewport size rather than measured from the tree — so they hold at any resolution without a layout pass; in-game confirmation of the flip at low resolutions is still worth a look.
Install (manual)
Extract Sts2SkinManager/ from the attached zip into <Slay the Spire 2>/mods/. The zip now ships two DLLs — Sts2SkinManager.dll and Sts2.ModKit.dll (the shared library the settings bridge loads); keep both side by side. Steam Workshop subscribers get the same build automatically as item 3747513223.
v0.27.0 — mark inert order in the Mixed panel + wrapping tooltips + preview z-order
v0.27.0 — mark inert order in the Mixed panel + wrapping tooltips + preview z-order
Fixed — the ⚠ marker never reached the mods that need it
- v0.26.0 marked card packs whose priority order is inert, but only in the Cards panel — and in practice every mod-private pack lives somewhere else. On a real install:
ATA_IronCladandATA_Silentare mixed mods (they ship a spine, so they route to the Mixed panel), andFGOCore/ArtoriaCasterare custom-character mods that are deliberately skipped from every panel. The Cards panel held onlyRegentCardsAnimeReworkandAliceDefectCard, both shared-path — correctly unmarked. The result: the marker was unreachable on that install. The Mixed panel now carries the same marker. - The data existed but never reached the badge: the boot log's
card_mode:comes straight from the path scan, while the badge readsDetectedSkinMod.CardOverrideMode, which v0.26.0 only populated on the Cards branch. The Character branches (which feed the Mixed panel) left it at the default.
Added — the Mixed panel's order is judged on the body too, not just the cards
- A mixed row's order is a single pck-mount lever governing every domain the mod ships, so the row is labelled from body and cards combined: both mod-private → ⚠ (the order does nothing at all), one of each → ◐ (it moves part of the mod), both shared → unmarked.
- Measuring the body mattered, and overturned the obvious guess. The intuition was "the order still moves the body, just not the cards" — false for ATA: it namespaces its spine (
res://ATA_IronClad/animations/characters/…) and itscreature_visualsscene, so a DLL decides its body as well and the order governs neither. Verified across installed pcks:animationsroot (base-owned) forAncientWaifus/raye/Ryoshu, own-id root forATA_IronClad/ATA_Silent. The path-root check also flagsNecrobinderOstyVaalmonicaModas a mod-private body. - Path judging is now one pass over
res://{root}/{rest}shared by both domains. Re-validated against the v0.26.0 card ground truth: 0 regressions across all 9 packs.
Fixed — long tooltips ran off the screen as one line
- Godot's stock tooltip is a
PopupPanel+Labelwith autowrap off, so every explanation rendered as a single line —blocked_mod_toggle_tooltipis 242 characters. Autowrap is a property, not a theme item, so aThemecannot fix it;_MakeCustomTooltipis the only hook, and it must be overridden on the concrete control. Added wrapping variants for the three control types carrying long tooltips (Label, Button, CheckBox) and applied them to all five: blocked-mod, custom-character and All Mods checkboxes, the 🧍/🃏 mixed toggles, and the new ⚠/◐ badges. - The wrap width is a
CustomMinimumSize, not decoration: an autowrapping Label reports its full single-line width as its minimum unless constrained, and the tooltip popup sizes to that minimum — so autowrap alone would still have rendered one line.
Fixed — the hover preview rendered behind the panel
- The dropdown row, the preview and the tab panel are all children of the character-select screen and all sat at
ZIndex = 1000. Godot breaks a Z tie by tree order, and the panel is attached last, so it drew over the preview image. The preview now sits at 3100 — above the panel, and above the drag indicator, whoseZIndex = 2000inside a row resolves to an effective 3000 through relative-Z inheritance.
Verification
- ⚠ badge in the Mixed panel, wrapped tooltips and the preview z-order confirmed in-game by the user. Release build clean.
- Unchanged from v0.26.0: this marks the inert case, it does not make ordering work for mod-private mods. Still awaiting the reporter's log (
card_mode:/body_mode:per mod) to decide whether driving RitsuLib's registry directly is worth it.
v0.26.0 — mark card packs whose priority is inert + fresh-install order fix
v0.26.0 — mark card packs whose priority is inert + fresh-install order fix
Added — Cards panel now says which rows the priority order can actually control
- A user reported that reordering card packs did nothing: four packs installed, some overlapping on the same card art, and "sometimes adjusting the priority in any order doesn't seem to affect anything". They were right, and it was not a bug in the ordering code — priority is structurally inert for a whole class of card pack, and the panel gave no hint of it.
- Card packs get their art in front of the player in one of two ways, and only one of them can be arbitrated by load order:
- Shared path — the pack writes to a path other packs also write to (
res://images/packed/card_portraits/…owned by the base game, or the sharedres://generated/assets/card_art/…tooling convention). Two packs reskinning the same card land on the same resource path, so they genuinely collide and Godot's last-mount-wins picks the winner. mod_list order — exactly what this panel controls — decides. Priority works. - Mod-private — the pack keeps its art under its own namespace (
res://ATA_IronClad/images/card_portraits/…) and injects it with a Harmony DLL. Nothing collides: every pack's art sits at its own distinct path, and the DLL decides whatCardModel.PortraitPathreturns. Mount order cannot reach that decision, so priority is inert — the arrows move the row and change nothing.
- Shared path — the pack writes to a path other packs also write to (
- Mod-private rows are now marked ⚠ with a greyed-out priority number and a tooltip explaining that ordering does not apply and to use the checkbox instead; packs that are half-and-half are marked ◐. Localized in all 16 languages. The boot log gains a
card_mode:field per pack so a "priority does nothing" report can be triaged from the log alone. - Detection is by the root segment of the card-art path compared against the pack's own mod id: a pack that files its art under its own id cannot collide with anything, by construction. This reads where the mod puts its files rather than which framework it links, so it needs no per-framework knowledge.
Fixed — a freshly installed card pack silently outranked every other pack
CardPackApplieronly rewrote mod_list entries the game had already written, skipping any pack absent from the list. But the game rebuilds mod_list at the end ofModManager.Initialize, one boot behind — andSortModListscores anything missing from mod_list at999999999, sorting it past every listed mod, so it mounts last and wins every conflict. MeanwhileSyncCardPacksappends newly detected packs at the bottom of the panel labelled lowest priority. The UI and reality were inverted until the next restart. Such packs are now materialized into mod_list at their correct position instead of being skipped.- The synthesized entry carries the mod's real
source(read fromModManager.Mods, falling back to the pck's location). This is load-bearing, not cosmetic:ModSettings.IsModDisabledmatches on(Id, Source, IsEnabled), so an entry with the wrong source would make the row's checkbox silently do nothing.
Fixed — content mods with an embedded DLL were mis-listed as card packs
- The base-card-namespace pattern matched
MegaCrit.Sts2.Core.Models.Cards.anywhere in a pck, but that string also appears in the IL metadata of any pck embedding a DLL that references base card types — nothing to do with card visuals. SlayTheUniverse (a content mod) carried 681 such strings (…Cards.Mocks,…Cards.Abrasive+<OnPlay>d__7) and was surfaced in the Cards panel, where reordering or disabling it could only confuse. The pattern now requires the_card_art/_portraitsuffix that a real asset path has, dropping it to 0 hits while keeping the genuine packs (AliceDefectCard 736, RegentCardsAnimeRework 300).
Verification
- Classification checked against every installed pack, using the shipped regexes: shared-path for
AliceDefectCard,RegentCardsAnimeRework(rootgenerated) andAncientWaifus,Ryoshu,raye(rootimages); mod-private forFGOCore,ArtoriaCaster,ATA_IronClad,ATA_Silent(root = own mod id). 9/9 as designed; SlayTheUniverse correctly drops out. - The resolution rules were read from the decompiled sources rather than inferred: RitsuLib merges competing packs by a registration-order
WriteOrdercounter (per-character scope beats global regardless of order; re-registering re-issues a higherWriteOrder), and the game'sSortModListapplies the999999999rule above. Confirmed against live files that SkinManager'ssettings.savewrite is not clobbered by the game:skin_choicesslot 1 (AliceDefectCard) correctly lands last inmod_list(index 48) and wins. - Debug + Release build clean. This release makes the inert case visible; it does not make ordering work for mod-private packs — that is not fixable from mount order. A follow-up may drive RitsuLib's registry directly for the RitsuLib-family packs; packs that patch
PortraitPathwith their own Harmony patch have no general fix and remain checkbox-only.
v0.25.0 — mixed-mod card toggle + macOS skin loading
v0.25.0 — mixed-mod card toggle + macOS skin loading
Added — "Vanilla cards / Mod cards" toggle for mixed mods (choose the body and the card art independently)
- Mixed mods bundle a character reskin and custom card art in one pck. You could already keep the cards and revert the body (the 🧍 "Selected look / Mod look" toggle, v0.15.0). This adds the card mirror: a per-row 🃏 "Vanilla cards / Mod cards" toggle in the Mixed panel. The two toggles are independent, so a mixed mod can be shown as any combination of {mod / vanilla} body × {mod / vanilla} cards.
- The toggle only appears for mixed mods that actually bundle card art (
VanillaCardsOverlayBuilder.HasRevertibleCards); mods with none never show it. - Persisted as
_vanilla_cards_modsinskin_choices.json; surfaced in the Applied summary as a(🃏 …)tag. Labels + tooltips localized in all 14 languages.
How it works — a Harmony prefix on CardModel.PortraitPath, not a pck overlay
- Card art in STS2 is resolved through a code path (
CardModel.PortraitPath, avirtualgetter that frameworks like RitsuLib/ATA already Harmony-patch), above the resource filesystem. So a mounted base-game overlay pck can never win — mounting is invisible to the resolver. Both directions of this toggle are therefore driven by our own higher-priority (Priority.First) prefix on that getter (CardVanillaPortraitPatch), which recomputes the path and short-circuits, beating any framework prefix and the original getter. - Vanilla cards (revert to stock): for a flagged character's pool, force the stock atlas path
res://images/atlases/card_atlas.sprites/{pool}/{entry}.tres. This reverts both direct-override skins and framework-injected (RitsuLib/ATA) card art, which a pck overlay could not touch. - Mod cards (surface the mod's art): a direct-override mixed skin (e.g.
raye) ships its card art as a card-atlas.tres.remap, which Godot ignores for a late-mounted pack — so the card stays vanilla even though the mod is selected. But it also ships the packed portrait png as a direct.ctexbyte-override at the base path (the same mechanism that makes its body work). For each mounted, non-reverted mixed mod we collect those cards (CollectPackedPortraitCardKeys) and redirect theirPortraitPathtores://images/packed/card_portraits/{pool}/{entry}.png, where the mod's ctex override wins — and which is also the exact path RitsuLib uses for card injection.
Verification
rayeselected: log confirmsmod-cards (Harmony): surfacing … [ironclad/break]andvanilla-cards (Harmony): forcing stock card art [ironclad]for the two directions.rayeships exactly one custom card (break; ctex 599 KB vs base 241 KB). Debug + Release build clean. In-game visual confirmation of the mod-cards direction pending (needsbreakin the deck).
v0.24.0 — shipped inside this release
Fixed — skins now load on macOS (Steam Workshop skins were invisible; character skins mis-classified)
- Steam Workshop skins were never discovered on macOS. SkinManager derived the workshop content root relative to the executable (
gameDir/../../workshop/content/<appId>). That holds on Windows/Linux, where the executable sits directly insteamapps/common/<game>/, but on macOS the executable is buried inSlayTheSpire2.app/Contents/MacOS/, so climbing two levels lands inside the .app bundle — the workshop folder is never found and no workshop-subscribed skin appears in the manager. Fix: read each subscribed mod's install folder back from STS2's ownModManager.Mods, which the game resolves via the Steam API (SteamUGC.GetItemInstallInfo) as an absolute path that is correct on every OS, and climb to the sharedcontent/<appId>root. The old relative derivation is kept only as a Windows/Linux fallback for whenModManageryields nothing (e.g. Steam not initialised). - Character skins were mis-classified on macOS because the base-character roster came up empty.
ScanBaseCharactersreadSlayTheSpire2.pckfrom next to the executable; inside the macOS app bundle that resource lives inContents/Resources/(STS2 itself probes the same../Resourcespath forrelease_info.json). With no base roster, every character skin failed the "skin for an existing character" test and was dropped. Fix: probeContents/Resources/SlayTheSpire2.pckas well; first hit wins. - Auto-restart is now cross-platform. The "apply → restart" helper only ever wrote a Windows
.bat, so even a detected skin couldn't be applied on macOS/Linux. It now branches on OS: Windows keeps the batch script; macOS/Linux write a POSIXshhelper that waits for the game process to exit (by pid) and relaunches via Steam's URL handler (openon macOS,xdg-openon Linux).
Why
- A macOS user reported that skin mods do not load. Root cause was OS-specific path handling, not the skins themselves: the game is packaged as a
.appbundle on macOS, which invalidates every executable-relative path assumption made for Windows.
Note
- All three changes are no-ops on Windows (same resolved path de-duplicated / first-candidate hit / OS-gated branch), so existing Windows behavior is unchanged. Compiles clean (Debug + Release). Pending in-game verification on macOS — developed and built on Windows; logic is grounded in the decompiled game's own macOS path handling but not yet confirmed on a real macOS install.
v0.23.0 — disable-not-reorder skin blocking + library custom-character detection
v0.23.0 — load-order: disable instead of reorder
SkinManager no longer reorders settings.save's mod_list. Reordering shifted the load order of content/framework mods (RitsuLib and the custom characters built on it), which reshuffled the numeric ids in the game's ModelIdSerializationCache — so an existing progress.save would mis-map and the player's progress appeared "not loaded." The reorder also never reliably stuck, so it re-armed the restart modal every boot.
- A skin that loads before SkinManager (so the
TryLoadModintercept can't catch it) is now blocked by disabling it (is_enabled = false) instead of reordering. Disabling is order-preserving and content-safe (a managed skin defines noModelDbentities). Skins that load after SkinManager are unaffected. Resolution is deterministic — one restart, no modal loop. - Hard guard: never auto-disables BaseLib,
Sts2*sister mods, or any mod whose DLL defines game-entity subclasses (a content mod) — those are left loaded-but-unblocked so their save data and dependencies stay intact.
v0.22.0 — generic library-based custom-character detection
- Custom characters built on any framework (RitsuLib, not just BaseLib) are now recognized via a framework-agnostic signal: a character-select asset for a non-base character id. Fixes RitsuLib characters (e.g. Hornet) being mis-classified as a base-character skin and DLL-blocked so they never appeared in Character Select.
- Manifest dependencies in object form (
{ "id": ..., "min_version": ... }) are now parsed, not just bare strings. - Fallback: every DLL-blocked mod is also surfaced in the Other Mods tab with a restore toggle, so a misclassified mod can always be switched back on.
v0.21.0 — load-order conflict auto-resolution + duplicate-list fix
v0.21.0 — load-order conflict auto-resolution + duplicate-list fix
Changed — load-order self-bootstrap no longer fights other "force-first" mods
- SkinManager now only ensures it loads before the skin mods it manages, not absolute-first in the mod list. Previously it pulled itself to index 0 of
settings.save'smod_liston every boot where it wasn't already first. That meant any other mod that also forces itself to the front (a core-patch or UI framework) could trigger a tug-of-war: each boot one mod reorders ahead of the other, re-arming the restart modal indefinitely. - The new rule (
LoadOrderEnforcer.EnsureBeforeTargetsInModList) computes the earliest mod_list index among the detected character-skin mods and only moves SkinManager when it sits at or behind that target — placing it just ahead of the earliest skin mod. Unrelated front-runners are ignored: they can keep index 0 forever, because SkinManager only needs to precede the mods itsTryLoadMod/LoadResourcePackprefixes manage. Since the game appends newly-installed mods to the end of the list (SortModListgives un-listed mods priority999999999), later additions land behind us automatically — no re-trigger.
Added — deterministic auto-resolution of an unwinnable load-order war
- The reorder target is now precise: only the character skins SkinManager actually suppresses (every detected skin minus the active pick and minus any already resolved by disable). It no longer tries to precede the skin you actually chose — order is irrelevant for a mod it doesn't block. A side benefit: with no suppressed skins, SkinManager stops forcing a reorder/restart at all.
- One-boot conflict detection. A persisted
_load_order_reordered_last_bootflag records whether we reordered. If we reordered last boot and are still behind a suppressed skin this boot, another "force-first" mod is overriding us — detected in a single restart instead of several. - Auto-resolve by persistent-disable instead of giving up. On detection, the blocking skin mod(s) are disabled in
settings.save(is_enabled = false). Because the game'sRemoveDisabledModsstrips disabled mods before any mod loads, the competing mod can't re-sort itself to the front — its code never runs. The conflict ends deterministically in one restart, with no user action. The visual result is identical (a suppressed skin doesn't apply either way); the mod simply shows as disabled in the game's own mod menu. Resolved ids are tracked in_load_order_resolved_by_disable. - Auto-heal. If the user later picks a skin we'd disabled, it is re-enabled automatically (one restart); entries for uninstalled mods are dropped.
- A "conflict resolved" restart prompt (
load_order_conflict_title/load_order_conflict_body) across all 16 languages.
Fixed
- Duplicate mod_list entries no longer trigger a spurious reorder. Real
settings.savefiles can list the same mod id more than once (observed:Sts2SkinManagerat both index 0 and index 59). The position lookup now uses each id's FIRST occurrence, so a stray late duplicate of ourselves is no longer read as "behind the skin mods" and does not cause a needless reorder/restart on every boot. - No restart prompt during headless / automated boots.
RestartCountdownModalnow no-ops whenDisplayServer.GetName() == "headless"(or--headlessis on the command line). The file mutations that warrant the restart already happened synchronously, so the next real launch picks them up — this just prevents a 10-second auto-restart from relaunching a headless process in a loop.
Verified
- Pure load-order logic covered by an offline harness (19 cases incl. reorder index math, front-runner-ignore, duplicate lists).
- All four runtime paths exercised end-to-end via real
--headlessboots against the live mod set (13 skin mods): regression (no-op), first reorder, conflict escalation (disable), and auto-heal (re-enable) — each confirmed against the resultingsettings.save/skin_choices.jsonand logs.
v0.20.0 — Applied tab shows each mod's actual load state
v0.20.0 — Applied tab shows each mod's actual load state
Added — Applied tab now shows each mod's actual load state
- A new "Actual load state" section reads the game's real
ModManagerstate, not just the boot snapshot ofsettings.save. This is the authoritative answer to "which skin mod is actually active right now", and it resolves a confusing gap: a character-skin DLL that SkinManager blocks at runtime (because it isn't the picked variant) is suppressed via theTryLoadModintercept, which never writesis_enabled— so the game's own mod menu kept showing it as enabled even though it wasn't loaded. - Per-mod badges make the source of each state explicit: ✅ loaded · ⛔ blocked by Skin Manager (runtime DLL-block, not the active pick) · 🚫 disabled in the mod menu (
is_enabled = false) · ❌ failed to load · ⟳ added at runtime (restart to apply) · ❔ not loaded. The ⛔/🚫 split tells you why a mod isn't active. The section is included in the 📋 Copy summary for bug reports. - Display-only: no change to toggling, mounting, or blocking behaviour — purely restores visibility.
v0.19.0 — manage Steam Workshop–subscribed skins
Added — manage Steam Workshop–subscribed skins
- SkinManager now scans Steam Workshop subscriptions, not just the local
mods/folder. Workshop subscriptions install outsidemods/— understeamapps/workshop/content/2868840/{itemId}/— which STS2 itself loads via the Steam API (SteamUGC.GetItemInstallInfo). Until now SkinManager only walkedmods/, so a skin you subscribed to on the Workshop never appeared in the panel and couldn't be previewed, toggled, or layered. - The Workshop root is derived relative to the game install (
steamapps/common/Slay the Spire 2→../../workshop/content/2868840), so no Steam binding is needed — Steam guarantees workshop content lives in the same library as the game. The root is only added when the folder exists. - Local
mods/wins over a Workshop duplicate. When the same mod id is present in both locations, the local copy is kept — matching STS2's own loader, which disables the Workshop version of a duplicate id. Reading from Steam's managed folder is safe: SkinManager never modifies scanned mod files (mounting is in-memory via theLoadResourcePackintercept).
Install: unzip into your STS2 mods/ folder so the layout is mods/Sts2SkinManager/Sts2SkinManager.dll.
v0.18.0 — detect ClymandSaru template-replacement skins
Recognise and manage ClymandSaru-framework skins (e.g. jasmine_q→Ironclad, zhubao_q→Defect) as character skins. These reskin an existing character via a {modId}_config.cfg template_replacements map + a runtime DLL patch, shipping no standard animations/characters/{char}/ paths — so they were previously invisible to Skin Manager. The new ClymandSaruSkinReader reads the config as the authoritative target, which also fixes the byte-frequency suggester mis-assigning non-Ironclad skins to Ironclad (every ClymandSaru DLL embeds the literal "ironclad" default template). Creature-only reskins (e.g. reven_q→byrdpip) that target no player character are left to normal auto-mount.
See CHANGELOG.md for details.
v0.17.0 — Custom Characters tab + cosmetic-utility false-positive fix
Added — Custom Characters tab
A dedicated tab to enable/disable BaseLib-style custom-character mods directly from the panel. Drives the same settings.save mod-list toggle as the Other tab (Save/Discard apply; restart to take effect). Appears only when a custom character is installed.
Fixed — texture/cosmetic utilities no longer mistaken for character skins
Mods that wholesale-retexture cards/relics/powers/potions (e.g. CustomCardTextureLoaderSG) patch NCharacterSelectButton just to swap the select-screen button texture, which previously tripped the DLL-skin detector — a stale assignment could then DLL-block the utility when a real skin for that character was active.
New Signal B (CosmeticUtilityDetector): a DLL referencing ≥2 base content-model types (relic/power/potion) with no per-character identity is auto-skipped as a global utility. A HasCharacterSignal guard (concrete-patch / byte-frequency / manifest keyword) protects themed skins that also retexture content — they still name their character, so they stay managed. Stale matching assignments auto-heal to _dll_skin_skipped on boot.
Install: extract Sts2SkinManager/ into your STS2 mods/ folder.