using System; using System.Collections.Generic; using System.IO; using System.Reflection; using BepInEx; using BepInEx.Configuration; using BepInEx.Logging; using BepInEx.Unity.IL2CPP; using HarmonyLib; using Il2CppInterop.Runtime; using Il2CppInterop.Runtime.InteropTypes.Arrays; using Skopje.Modding; using UnityEngine; using UnityEngine.Rendering; namespace Skopje.CustomPlayerSkins { [BepInPlugin(PluginGuid, PluginName, PluginVersion)] public class CustomPlayerSkinsPlugin : BasePlugin { public const string PluginGuid = "com.skopje.customplayerskins"; public const string PluginName = "Skopje Custom Player Skins"; public const string PluginVersion = "1.3.0"; internal static ManualLogSource LogSource; internal static ConfigEntry Enabled; static Harmony _harmony; static Il2CppSystem.Action _onPlayerStarted; public override void Load() { LogSource = Log; Enabled = Config.Bind("Skin", "Enabled", true, "Replace each player outfit with its own PNG in the CustomSkins folder next to this plugin."); _harmony = new Harmony(PluginGuid); var setTexture = AccessTools.Method(typeof(Material), "SetTexture", new[] { typeof(string), typeof(Texture) }); if (setTexture != null) _harmony.Patch(setTexture, prefix: new HarmonyMethod(typeof(SkinPatches), nameof(SkinPatches.BeforeSetTexture))); var setTextureId = AccessTools.Method(typeof(Material), "SetTexture", new[] { typeof(int), typeof(Texture) }); if (setTextureId != null) _harmony.Patch(setTextureId, prefix: new HarmonyMethod(typeof(SkinPatches), nameof(SkinPatches.BeforeSetTextureId))); var refresh = AccessTools.Method(typeof(SkopjePlayer), "RefreshActiveModels"); if (refresh == null) LogSource.LogWarning("SkopjePlayer.RefreshActiveModels was not found. The skins are applied when a player spawns."); else _harmony.Patch(refresh, postfix: new HarmonyMethod(typeof(SkinPatches), nameof(SkinPatches.AfterRefreshModels))); _onPlayerStarted = DelegateSupport.ConvertDelegate>(new Action(SkinReplacer.ApplyPlayer)); ModAPI.add_OnPlayerStartedClient(_onPlayerStarted); LogSource.LogInfo($"{PluginName} {PluginVersion} loaded. Edit the PNGs in CustomSkins: singleplayer, ranger, medic, engineer, fighter (Warrior), prp, and hands."); } public override bool Unload() { if (_harmony != null) _harmony.UnpatchSelf(); if (_onPlayerStarted != null) ModAPI.remove_OnPlayerStartedClient(_onPlayerStarted); SkinReplacer.Restore(); return true; } } static class SkinPatches { public static void AfterRefreshModels(SkopjePlayer __instance) { SkinReplacer.ApplyPlayer(__instance); } public static void BeforeSetTexture(string name, ref Texture value) { if (name == "_MainTex") SkinReplacer.Redirect(ref value); } public static void BeforeSetTextureId(int nameID, ref Texture value) { if (nameID == Shader.PropertyToID("_MainTex")) SkinReplacer.Redirect(ref value); } } static class SkinReplacer { sealed class Outfit { public string FileName; public string DumpMaterial; public string SourceTexture; public string[] Materials; public Texture2D Texture; public DateTime WriteTimeUtc; } // Fighter is the multiplayer class shown as Warrior on the player icons. static readonly Outfit[] Outfits = { new Outfit { FileName = "singleplayer.png", DumpMaterial = "MAT_Pioneer_Default_Clothes", SourceTexture = "DIF_Pioneer_Clothes_Default", Materials = new[] { "MAT_Pioneer_Default_Clothes", "MAT_Pioneer_Default_Clothes_FP" }, }, new Outfit { FileName = "ranger.png", DumpMaterial = "MAT_Pioneer_Ranger_Clothes", SourceTexture = "DIF_Pioneer_Clothes_Ranger", Materials = new[] { "MAT_Pioneer_Ranger_Clothes", "MAT_Pioneer_Ranger_Clothes_FP" }, }, new Outfit { FileName = "medic.png", DumpMaterial = "MAT_Pioneer_Medic_Clothes", SourceTexture = "DIF_Pioneer_Clothes_Medic", Materials = new[] { "MAT_Pioneer_Medic_Clothes", "MAT_Pioneer_Medic_Clothes_FP" }, }, new Outfit { FileName = "engineer.png", DumpMaterial = "MAT_Pioneer_Engi_Clothes", SourceTexture = "DIF_Pioneer_Clothes_Engi", Materials = new[] { "MAT_Pioneer_Engi_Clothes", "MAT_Pioneer_Engi_Clothes_FP" }, }, new Outfit { FileName = "fighter.png", DumpMaterial = "MAT_Pioneer_Fighter_Clothes", SourceTexture = "DIF_Pioneer_Clothes_Fighter", Materials = new[] { "MAT_Pioneer_Fighter_Clothes", "MAT_Pioneer_Fighter_Clothes_FP" }, }, new Outfit { FileName = "prp.png", DumpMaterial = "MAT_PRP", SourceTexture = "DIF_PRP", Materials = new[] { "MAT_PRP", "MAT_PRP_FP" }, }, new Outfit { FileName = "hands.png", DumpMaterial = "MAT_Pioneer_Naked", SourceTexture = "DIF_FPSHands_Naked", Materials = new[] { "MAT_Pioneer_Naked" }, }, }; static readonly Dictionary _originals = new Dictionary(); static readonly Dictionary _materials = new Dictionary(); static readonly Dictionary _renderers = new Dictionary(); static readonly MaterialPropertyBlock _block = new MaterialPropertyBlock(); static float _nextFileCheck; static bool _loggedApply; public static void ApplyPlayer(SkopjePlayer player) { if (!CustomPlayerSkinsPlugin.Enabled.Value) { RevertLive(); return; } if (player == null || !EnsureTextures(player)) return; int applied = ApplyPlayerRenderers(player); if (applied > 0 && !_loggedApply) { _loggedApply = true; CustomPlayerSkinsPlugin.LogSource.LogInfo($"Applied {applied} player material{(applied == 1 ? "" : "s")} from CustomSkins."); } } public static void Restore() { RevertLive(); _originals.Clear(); _materials.Clear(); _renderers.Clear(); for (int i = 0; i < Outfits.Length; i++) { if (Outfits[i].Texture == null) continue; UnityEngine.Object.Destroy(Outfits[i].Texture); Outfits[i].Texture = null; } } static void RevertLive() { RevertMaterials(); ClearRendererOverrides(); } static void RevertMaterials() { foreach (var pair in _materials) { Material material = pair.Value; if (material != null && _originals.TryGetValue(pair.Key, out Texture original)) material.SetTexture("_MainTex", original); } } static void ClearRendererOverrides() { foreach (var pair in _renderers) { Renderer renderer = pair.Value; if (renderer == null) continue; Material[] materials = renderer.sharedMaterials; if (materials == null) continue; for (int slot = 0; slot < materials.Length; slot++) { Material material = materials[slot]; if (material == null || !_originals.TryGetValue(material.GetInstanceID(), out Texture original)) continue; renderer.GetPropertyBlock(_block, slot); _block.SetTexture("_MainTex", original); renderer.SetPropertyBlock(_block, slot); } } } static int ApplySkins(Il2CppReferenceArray skins) { if (skins == null) return 0; int applied = 0; for (int i = 0; i < skins.Length; i++) { PlayerSkin skin = skins[i]; if (skin == null) continue; applied += ApplyMaterialArray(skin.FPMaterials); applied += ApplyMaterialArray(skin.TPMaterials); } return applied; } static int ApplyPlayerRenderers(SkopjePlayer player) { if (player == null) return 0; int applied = ApplySkins(player.PlayerSkins); applied += ApplyRenderer(player.FirstPersonRenderer); applied += ApplyRenderer(player.ThirdPersonRenderer); applied += ApplyRenderer(player.ThirdPersonBackpackRenderer); applied += ApplyOn(player.ThirdPerson); applied += ApplyOn(player.FirstPerson); if (player.SkopjeCharacterController != null) applied += ApplyOn(player.SkopjeCharacterController.MeshRoot); if (player.UtilityController != null) applied += ApplyOn(player.UtilityController.FPSHolder); if (player.WeaponsController != null) applied += ApplyWeapons(player.WeaponsController); return applied; } static int ApplyWeapons(WeaponsController weapons) { if (weapons == null) return 0; int applied = 0; applied += ApplyOn(weapons.Socket); applied += ApplyOn(weapons.ThrowSocket); applied += ApplyOn(weapons.PRPObject); applied += ApplyOn(weapons.BrokenPRPObject); applied += ApplyOn(weapons.KnifeObject); var list = weapons.AvailableWeapons; if (list == null) return applied; for (int i = 0; i < list.Count; i++) { Weapon weapon = list[i]; if (weapon == null) continue; applied += ApplyOn(weapon.WeaponModel); applied += ApplyOn(weapon.LeftHandWeaponModel); applied += ApplyOn(weapon.PhysicsWeaponModel); applied += ApplyOn(weapon.PhysicsLeftHandWeaponModel); } return applied; } static int ApplyOn(GameObject root) { if (root == null) return 0; return ApplyUnder(root.transform); } static int ApplyOn(Component root) { if (root == null) return 0; return ApplyUnder(root.transform); } static int ApplyUnder(Transform root) { if (root == null) return 0; Renderer[] found = root.GetComponentsInChildren(true); int applied = 0; for (int i = 0; i < found.Length; i++) applied += ApplyRenderer(found[i]); return applied; } static int ApplyRenderer(Renderer renderer) { if (renderer == null) return 0; int applied = ApplyMaterialSlots(renderer, renderer.sharedMaterials, false); applied += ApplyMaterialSlots(renderer, renderer.materials, true); return applied; } static int ApplyMaterialSlots(Renderer renderer, Material[] materials, bool propertyBlock) { if (materials == null) return 0; int applied = 0; for (int i = 0; i < materials.Length; i++) { Outfit outfit = Resolve(materials[i]); if (outfit == null) continue; applied += ApplyMaterial(materials[i]); _renderers[renderer.GetInstanceID()] = renderer; if (!propertyBlock) continue; renderer.GetPropertyBlock(_block, i); _block.SetTexture("_MainTex", outfit.Texture); renderer.SetPropertyBlock(_block, i); } return applied; } static int ApplyMaterialArray(Il2CppReferenceArray materials) { if (materials == null) return 0; int applied = 0; for (int i = 0; i < materials.Length; i++) applied += ApplyMaterial(materials[i]); return applied; } static int ApplyMaterial(Material material) { Outfit outfit = Resolve(material); if (outfit == null) return 0; int id = material.GetInstanceID(); if (!_originals.ContainsKey(id)) { var current = material.GetTexture("_MainTex"); _originals[id] = current; _materials[id] = material; CopySampling(outfit.Texture, current); } if (material.GetTexture("_MainTex") == outfit.Texture) return 0; material.SetTexture("_MainTex", outfit.Texture); return 1; } static Outfit Resolve(Material material) { if (material == null || !material.HasProperty("_MainTex")) return null; Outfit byName = FindOutfit(material.name); if (byName != null && byName.Texture != null) return byName; Texture current = material.GetTexture("_MainTex"); if (current == null) return null; for (int i = 0; i < Outfits.Length; i++) { Outfit outfit = Outfits[i]; if (outfit.Texture == null) continue; if (current == outfit.Texture) return outfit; if (TextureNameMatches(current.name, outfit.SourceTexture)) return outfit; } return null; } public static void Redirect(ref Texture value) { if (value == null || !CustomPlayerSkinsPlugin.Enabled.Value) return; for (int i = 0; i < Outfits.Length; i++) { Outfit outfit = Outfits[i]; if (outfit.Texture == null || value == outfit.Texture) continue; if (TextureNameMatches(value.name, outfit.SourceTexture)) { value = outfit.Texture; return; } } } static bool TextureNameMatches(string textureName, string sourceName) { if (string.IsNullOrEmpty(textureName) || string.IsNullOrEmpty(sourceName)) return false; return textureName == sourceName || textureName.StartsWith(sourceName + " ", StringComparison.Ordinal); } static Outfit FindOutfit(string materialName) { if (string.IsNullOrEmpty(materialName)) return null; for (int i = 0; i < Outfits.Length; i++) { string[] names = Outfits[i].Materials; for (int n = 0; n < names.Length; n++) { string expected = names[n]; if (materialName == expected || materialName.StartsWith(expected + " (", StringComparison.Ordinal)) return Outfits[i]; } } return null; } static void CopySampling(Texture2D target, Texture source) { if (target == null || source == null) return; target.wrapMode = source.wrapMode; target.filterMode = source.filterMode; target.anisoLevel = source.anisoLevel; } static bool EnsureTextures(Component source) { if (AnyLoaded() && Time.unscaledTime < _nextFileCheck) return true; _nextFileCheck = Time.unscaledTime + 1f; string directory = SkinsDirectory(); int ready = 0; for (int i = 0; i < Outfits.Length; i++) { if (EnsureOutfit(Outfits[i], directory, source)) ready++; } if (ready == 0) { CustomPlayerSkinsPlugin.LogSource.LogWarning("No outfit PNGs are ready in " + directory); return false; } return true; } static bool AnyLoaded() { for (int i = 0; i < Outfits.Length; i++) { if (Outfits[i].Texture != null) return true; } return false; } static bool EnsureOutfit(Outfit outfit, string directory, Component source) { string path = Path.Combine(directory, outfit.FileName); if (!File.Exists(path)) { if (!TryDumpSample(outfit, path, source)) return outfit.Texture != null; } DateTime writeTime; try { writeTime = File.GetLastWriteTimeUtc(path); } catch (Exception ex) { CustomPlayerSkinsPlugin.LogSource.LogError("Could not read " + outfit.FileName + ": " + ex.Message); return outfit.Texture != null; } if (outfit.Texture != null && outfit.WriteTimeUtc == writeTime) return true; byte[] bytes; try { bytes = File.ReadAllBytes(path); } catch (Exception ex) { CustomPlayerSkinsPlugin.LogSource.LogError("Could not read " + outfit.FileName + ": " + ex.Message); return outfit.Texture != null; } var texture = new Texture2D(2, 2, TextureFormat.RGBA32, true); texture.name = "SkopjeCustomSkin_" + Path.GetFileNameWithoutExtension(outfit.FileName); texture.hideFlags = HideFlags.HideAndDontSave; if (!ImageConversion.LoadImage(texture, bytes)) { UnityEngine.Object.Destroy(texture); CustomPlayerSkinsPlugin.LogSource.LogError(outfit.FileName + " is not a readable image: " + path); return outfit.Texture != null; } if (outfit.Texture != null) UnityEngine.Object.Destroy(outfit.Texture); outfit.Texture = texture; outfit.WriteTimeUtc = writeTime; _loggedApply = false; CustomPlayerSkinsPlugin.LogSource.LogInfo($"Loaded {outfit.FileName} ({texture.width}x{texture.height})"); return true; } static Texture FindSourceTexture(Component root, string materialName) { if (root == null || string.IsNullOrEmpty(materialName)) return null; Renderer[] renderers = root.GetComponentsInChildren(true); for (int i = 0; i < renderers.Length; i++) { Renderer renderer = renderers[i]; if (renderer == null) continue; Material[] materials = renderer.sharedMaterials; if (materials == null) continue; for (int slot = 0; slot < materials.Length; slot++) { Material material = materials[slot]; if (material == null || !NameIs(material.name, materialName) || !material.HasProperty("_MainTex")) continue; Texture current = material.GetTexture("_MainTex"); if (_originals.TryGetValue(material.GetInstanceID(), out Texture original) && original != null) current = original; if (current != null) return current; } } return null; } static bool NameIs(string materialName, string expected) { return materialName == expected || materialName.StartsWith(expected + " (", StringComparison.Ordinal); } static bool TryDumpSample(Outfit outfit, string path, Component root) { Texture source = FindSourceTexture(root, outfit.DumpMaterial); if (source == null) return false; byte[] png = EncodePng(source); if (png == null || png.Length == 0) return false; try { Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, png); CustomPlayerSkinsPlugin.LogSource.LogInfo("Dumped " + outfit.FileName + " from the game into CustomSkins."); return true; } catch (Exception ex) { CustomPlayerSkinsPlugin.LogSource.LogError("Could not write " + outfit.FileName + ": " + ex.Message); return false; } } static byte[] EncodePng(Texture source) { var readable = source as Texture2D; bool temporary = false; RenderTexture rt = null; RenderTexture previous = RenderTexture.active; try { if (readable == null || !readable.isReadable) { rt = RenderTexture.GetTemporary(source.width, source.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); Graphics.Blit(source, rt); RenderTexture.active = rt; readable = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); readable.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); readable.Apply(); temporary = true; } var encoded = ImageConversion.EncodeToPNG(readable); if (encoded == null) return null; var bytes = new byte[encoded.Length]; for (int i = 0; i < encoded.Length; i++) bytes[i] = encoded[i]; return bytes; } finally { RenderTexture.active = previous; if (rt != null) RenderTexture.ReleaseTemporary(rt); if (temporary && readable != null) UnityEngine.Object.Destroy(readable); } } static string SkinsDirectory() { string dllDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); if (!string.IsNullOrEmpty(dllDir)) return Path.Combine(dllDir, "CustomSkins"); return Path.Combine(Paths.PluginPath, "CustomSkins"); } } }