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 Skopje.Modding; using UnityEngine; namespace Skopje.CustomWeaponSkins { [BepInPlugin(PluginGuid, PluginName, PluginVersion)] public class CustomWeaponSkinsPlugin : BasePlugin { public const string PluginGuid = "com.skopje.customweaponskins"; public const string PluginName = "Skopje Custom Weapon Skins"; public const string PluginVersion = "1.1.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 weapon with its own PNG in the WeaponSkins 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(WeaponSkinPatches), nameof(WeaponSkinPatches.BeforeSetTexture))); var setTextureId = AccessTools.Method(typeof(Material), "SetTexture", new[] { typeof(int), typeof(Texture) }); if (setTextureId != null) _harmony.Patch(setTextureId, prefix: new HarmonyMethod(typeof(WeaponSkinPatches), nameof(WeaponSkinPatches.BeforeSetTextureId))); _onPlayerStarted = DelegateSupport.ConvertDelegate>(new Action(WeaponSkinReplacer.ApplyPlayer)); ModAPI.add_OnPlayerStartedClient(_onPlayerStarted); LogSource.LogInfo($"{PluginName} {PluginVersion} loaded. Edit the PNGs in WeaponSkins. Each file is one weapon: kalash, m143, knife, lmg_heavy, lmg_medium, bozdogun, hammer, rusty_pipe, wrench, toxic_axe, electric_blade, udar, revolver, pupin, karab, osa, vector, gruev_shotgun, winko, skull, grom, snooper, spore_gun, tesla_gun, and frag_grenade."); } public override bool Unload() { if (_harmony != null) _harmony.UnpatchSelf(); if (_onPlayerStarted != null) ModAPI.remove_OnPlayerStartedClient(_onPlayerStarted); WeaponSkinReplacer.Restore(); return true; } } static class WeaponSkinPatches { public static void BeforeSetTexture(string name, ref Texture value) { if (name == "_MainTex") WeaponSkinReplacer.Redirect(ref value); } public static void BeforeSetTextureId(int nameID, ref Texture value) { if (nameID == Shader.PropertyToID("_MainTex")) WeaponSkinReplacer.Redirect(ref value); } } static class WeaponSkinReplacer { sealed class WeaponSkin { public string FileName; public string DumpMaterial; public string SourceTexture; public string[] Materials; public Texture2D Texture; public DateTime WriteTimeUtc; } // One file per weapon. First-person, item, scope, and charged variants share that weapon's albedo. static readonly WeaponSkin[] Weapons = { new WeaponSkin { FileName = "kalash.png", DumpMaterial = "MAT_Weapon_Kalash", SourceTexture = "DIF_Weapon_Kalash", Materials = new[] { "MAT_Weapon_Kalash", "MAT_Weapon_Kalash_FP", "MAT_Weapon_Kalash_Item" }, }, new WeaponSkin { FileName = "m143.png", DumpMaterial = "MAT_Weapon_M143", SourceTexture = "DIF_Weapon_M143", Materials = new[] { "MAT_Weapon_M143", "MAT_Weapon_M143_FP", "MAT_Weapon_M143_Item" }, }, new WeaponSkin { FileName = "knife.png", DumpMaterial = "MAT_Knife_FP", SourceTexture = "DIF_Knife", Materials = new[] { "MAT_Knife_FP" }, }, new WeaponSkin { FileName = "lmg_heavy.png", DumpMaterial = "MAT_Weapon_LMG_Heavy", SourceTexture = "DIF_Weapon_LMG_Heavy", Materials = new[] { "MAT_Weapon_LMG_Heavy", "MAT_Weapon_LMG_Heavy_FP", "MAT_Weapon_LMG_Heavy_Item" }, }, new WeaponSkin { FileName = "lmg_medium.png", DumpMaterial = "MAT_Weapon_LMGMedium", SourceTexture = "DIF_Weapon_LMGMedium", Materials = new[] { "MAT_Weapon_LMGMedium", "MAT_Weapon_LMGMedium_FP", "MAT_Weapon_LMGMedium_Item" }, }, new WeaponSkin { FileName = "bozdogun.png", DumpMaterial = "MAT_Weapon_Bozdogun", SourceTexture = "DIF_Weapon_Bozdogun", Materials = new[] { "MAT_Weapon_Bozdogun", "MAT_Weapon_Bozdogun_FP", "MAT_Weapon_Bozdogun_FP_Charged", "MAT_Weapon_Bozdogun_Item" }, }, new WeaponSkin { FileName = "hammer.png", DumpMaterial = "MAT_Weapon_Hammer", SourceTexture = "DIF_Weapon_Slab", Materials = new[] { "MAT_Weapon_Hammer", "MAT_Weapon_Hammer_FP", "MAT_Weapon_Hammer_Item" }, }, new WeaponSkin { FileName = "rusty_pipe.png", DumpMaterial = "MAT_Weapon_Rusty_Pipe", SourceTexture = "DIF_Rusty_Pipe", Materials = new[] { "MAT_Weapon_Rusty_Pipe", "MAT_Weapon_Rusty_Pipe_FP", "MAT_Weapon_Rusty_Pipe_Item" }, }, new WeaponSkin { FileName = "wrench.png", DumpMaterial = "MAT_Weapon_Wrench", SourceTexture = "DIF_Weapon_Wrench", Materials = new[] { "MAT_Weapon_Wrench", "MAT_Weapon_Wrench_FP", "MAT_Weapon_Wrench_Item" }, }, new WeaponSkin { FileName = "toxic_axe.png", DumpMaterial = "MAT_Weapon_Toxic_Axe", SourceTexture = "DIF_Toxic_Axe", Materials = new[] { "MAT_Weapon_Toxic_Axe", "MAT_Weapon_Toxic_Axe_FP" }, }, new WeaponSkin { FileName = "electric_blade.png", DumpMaterial = "MAT_Weapon_Electric_Blade", SourceTexture = "DIF_ElectricBlade", Materials = new[] { "MAT_Weapon_Electric_Blade", "MAT_Weapon_Electric_Blade_FP", "MAT_Weapon_Electric_Blade_Item" }, }, new WeaponSkin { FileName = "udar.png", DumpMaterial = "MAT_Weapon_UDAR", SourceTexture = "DIF_Weapon_UDAR", Materials = new[] { "MAT_Weapon_UDAR", "MAT_Weapon_UDAR_FP", "MAT_Weapon_UDAR_Item" }, }, new WeaponSkin { FileName = "revolver.png", DumpMaterial = "MAT_Weapon_Revolver", SourceTexture = "DIF_Weapon_Revolver", Materials = new[] { "MAT_Weapon_Revolver", "MAT_Weapon_Revolver_FP", "MAT_Weapon_Revolver_Item" }, }, new WeaponSkin { FileName = "pupin.png", DumpMaterial = "MAT_Pupin", SourceTexture = "DIF_Weapon_Pupin", Materials = new[] { "MAT_Pupin", "MAT_Pupin_FP", "MAT_Pupin_Item" }, }, new WeaponSkin { FileName = "karab.png", DumpMaterial = "MAT_Karab", SourceTexture = "DIF_Weapon_Karab", Materials = new[] { "MAT_Karab", "MAT_Karab_FP", "MAT_Karab_Item" }, }, new WeaponSkin { FileName = "osa.png", DumpMaterial = "MAT_Osa", SourceTexture = "DIF_Weapon_Osa", Materials = new[] { "MAT_Osa", "MAT_Osa_FP", "MAT_Osa_Item" }, }, new WeaponSkin { FileName = "vector.png", DumpMaterial = "MAT_Weapon_Vector", SourceTexture = "DIF_Weapon_Vector", Materials = new[] { "MAT_Weapon_Vector", "MAT_Weapon_Vector_FP", "MAT_Weapon_Vector_Item" }, }, new WeaponSkin { FileName = "gruev_shotgun.png", DumpMaterial = "MAT_GruevShotgun", SourceTexture = "DIF_Weapon_Gruev_Shotgun", Materials = new[] { "MAT_GruevShotgun", "MAT_GruevShotgun_FP", "MAT_GruevShotgun_Item" }, }, new WeaponSkin { FileName = "winko.png", DumpMaterial = "MAT_Weapon_Winko", SourceTexture = "DIF_Weapon_Winko", Materials = new[] { "MAT_Weapon_Winko", "MAT_Weapon_Winko_FP", "MAT_Weapon_Winko_Item" }, }, new WeaponSkin { FileName = "skull.png", DumpMaterial = "MAT_Weapon_Skull", SourceTexture = "DIF_Weapon_Skull", Materials = new[] { "MAT_Weapon_Skull", "MAT_Weapon_Skull_FP", "MAT_Weapon_Skull_Item", "MAT_Weapon_Skull_Empty" }, }, new WeaponSkin { FileName = "grom.png", DumpMaterial = "MAT_Weapon_Grom", SourceTexture = "DIF_Weapon_Grom", Materials = new[] { "MAT_Weapon_Grom", "MAT_Weapon_Grom_FP", "MAT_Weapon_Grom_Item" }, }, new WeaponSkin { FileName = "snooper.png", DumpMaterial = "MAT_Weapon_Snooper", SourceTexture = "DIF_Weapon_Snooper", Materials = new[] { "MAT_Weapon_Snooper", "MAT_Weapon_Snooper_FP", "MAT_Weapon_Snooper_Item", "MAT_Weapon_Snooper_Scope", "MAT_Weapon_Snooper_Scope_FP" }, }, new WeaponSkin { FileName = "spore_gun.png", DumpMaterial = "MAT_Weapon_SporeGun", SourceTexture = "DIF_Weapon_SporeGun", Materials = new[] { "MAT_Weapon_SporeGun", "MAT_Weapon_SporeGun_FP", "MAT_Weapon_SporeGun_Item" }, }, new WeaponSkin { FileName = "tesla_gun.png", DumpMaterial = "MAT_Weapon_TeslaGun", SourceTexture = "DIF_Weapon_Teslagun", Materials = new[] { "MAT_Weapon_TeslaGun", "MAT_Weapon_TeslaGun_FP", "MAT_Weapon_TeslaGun_Item" }, }, new WeaponSkin { FileName = "frag_grenade.png", DumpMaterial = "MAT_FragGrenade", SourceTexture = "DIF_FragGrenade", Materials = new[] { "MAT_FragGrenade" }, }, }; 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; static bool _applied; public static void ApplyPlayer(SkopjePlayer player) { if (!CustomWeaponSkinsPlugin.Enabled.Value) { if (_applied) { RevertMaterials(); ClearRendererOverrides(); _applied = false; _loggedApply = false; } return; } if (player == null || !EnsureTextures(player)) return; int applied = ApplyPlayerWeapons(player); if (applied > 0) { _applied = true; if (!_loggedApply) { _loggedApply = true; CustomWeaponSkinsPlugin.LogSource.LogInfo($"Applied {applied} weapon material{(applied == 1 ? "" : "s")} from WeaponSkins."); } } } public static void Restore() { RevertMaterials(); ClearRendererOverrides(); _applied = false; _originals.Clear(); _materials.Clear(); _renderers.Clear(); for (int i = 0; i < Weapons.Length; i++) { if (Weapons[i].Texture == null) continue; UnityEngine.Object.Destroy(Weapons[i].Texture); Weapons[i].Texture = null; } } public static void Redirect(ref Texture value) { if (value == null || !CustomWeaponSkinsPlugin.Enabled.Value) return; for (int i = 0; i < Weapons.Length; i++) { WeaponSkin weapon = Weapons[i]; if (weapon.Texture == null || value == weapon.Texture) continue; if (TextureNameMatches(value.name, weapon.SourceTexture)) { value = weapon.Texture; return; } } } 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 ApplyPlayerWeapons(SkopjePlayer player) { WeaponsController weapons = player.WeaponsController; 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; Material[] materials = renderer.sharedMaterials; if (materials == null) return 0; int applied = 0; for (int i = 0; i < materials.Length; i++) { WeaponSkin weapon = Resolve(materials[i]); if (weapon == null) continue; applied += ApplyMaterial(materials[i]); _renderers[renderer.GetInstanceID()] = renderer; renderer.GetPropertyBlock(_block, i); _block.SetTexture("_MainTex", weapon.Texture); renderer.SetPropertyBlock(_block, i); } return applied; } static int ApplyMaterial(Material material) { WeaponSkin weapon = Resolve(material); if (weapon == null) return 0; int id = material.GetInstanceID(); if (!_originals.ContainsKey(id)) { Texture current = material.GetTexture("_MainTex"); _originals[id] = current; _materials[id] = material; CopySampling(weapon.Texture, current); } if (material.GetTexture("_MainTex") == weapon.Texture) return 0; material.SetTexture("_MainTex", weapon.Texture); return 1; } static WeaponSkin Resolve(Material material) { if (material == null || !material.HasProperty("_MainTex")) return null; WeaponSkin byName = FindWeapon(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 < Weapons.Length; i++) { WeaponSkin weapon = Weapons[i]; if (weapon.Texture == null) continue; if (current == weapon.Texture) return weapon; if (TextureNameMatches(current.name, weapon.SourceTexture)) return weapon; } return null; } 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 WeaponSkin FindWeapon(string materialName) { if (string.IsNullOrEmpty(materialName)) return null; for (int i = 0; i < Weapons.Length; i++) { string[] names = Weapons[i].Materials; for (int n = 0; n < names.Length; n++) { string expected = names[n]; if (materialName == expected || materialName.StartsWith(expected + " (", StringComparison.Ordinal)) return Weapons[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 < Weapons.Length; i++) { if (EnsureWeapon(Weapons[i], directory, source)) ready++; } if (ready == 0) { CustomWeaponSkinsPlugin.LogSource.LogWarning("No weapon PNGs are ready in " + directory); return false; } return true; } static bool AnyLoaded() { for (int i = 0; i < Weapons.Length; i++) { if (Weapons[i].Texture != null) return true; } return false; } static bool EnsureWeapon(WeaponSkin weapon, string directory, Component source) { string path = Path.Combine(directory, weapon.FileName); if (!File.Exists(path)) { if (!TryDumpSample(weapon, path, source)) return weapon.Texture != null; } DateTime writeTime; try { writeTime = File.GetLastWriteTimeUtc(path); } catch (Exception ex) { CustomWeaponSkinsPlugin.LogSource.LogError("Could not read " + weapon.FileName + ": " + ex.Message); return weapon.Texture != null; } if (weapon.Texture != null && weapon.WriteTimeUtc == writeTime) return true; byte[] bytes; try { bytes = File.ReadAllBytes(path); } catch (Exception ex) { CustomWeaponSkinsPlugin.LogSource.LogError("Could not read " + weapon.FileName + ": " + ex.Message); return weapon.Texture != null; } var texture = new Texture2D(2, 2, TextureFormat.RGBA32, true); texture.name = "SkopjeCustomWeaponSkin_" + Path.GetFileNameWithoutExtension(weapon.FileName); texture.hideFlags = HideFlags.HideAndDontSave; if (!ImageConversion.LoadImage(texture, bytes)) { UnityEngine.Object.Destroy(texture); CustomWeaponSkinsPlugin.LogSource.LogError(weapon.FileName + " is not a readable image: " + path); return weapon.Texture != null; } if (weapon.Texture != null) UnityEngine.Object.Destroy(weapon.Texture); weapon.Texture = texture; weapon.WriteTimeUtc = writeTime; _loggedApply = false; CustomWeaponSkinsPlugin.LogSource.LogInfo($"Loaded {weapon.FileName} ({texture.width}x{texture.height})"); return true; } static bool TryDumpSample(WeaponSkin weapon, string path, Component root) { Texture source = FindSourceTexture(root, weapon.DumpMaterial); if (source == null || IsCustomTexture(source)) return false; byte[] png = EncodePng(source); if (png == null || png.Length == 0) return false; try { Directory.CreateDirectory(Path.GetDirectoryName(path)); File.WriteAllBytes(path, png); CustomWeaponSkinsPlugin.LogSource.LogInfo("Dumped " + weapon.FileName + " from the game into WeaponSkins."); return true; } catch (Exception ex) { CustomWeaponSkinsPlugin.LogSource.LogError("Could not write " + weapon.FileName + ": " + ex.Message); return false; } } 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 && !IsCustomTexture(original)) current = original; if (current == null || IsCustomTexture(current)) continue; return current; } } return null; } static bool NameIs(string materialName, string expected) { return materialName == expected || materialName.StartsWith(expected + " (", StringComparison.Ordinal); } static bool IsCustomTexture(Texture texture) { if (texture == null) return false; for (int i = 0; i < Weapons.Length; i++) { if (Weapons[i].Texture == texture) return true; } 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, "WeaponSkins"); return Path.Combine(Paths.PluginPath, "WeaponSkins"); } } }