using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; namespace NecromancerTome { /// /// Renders every trader in black and white (user request 2026-09-13: "сделать модельки всех /// торговцев полупрозрачными и чёрнобелыми", then after two in-game looks: "прозрачность у /// торговцев убираем совсем"). Fits the mod - the necromancer deals with the dead, and the /// only people still trading are not quite alive. /// /// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER. The user first said "убираем /// совсем", confirmed the result ("торговец стал непрозрачным и полностью чёрно-белым, как и /// требовалось"), and then asked for "лёгкую прозрачность, буквально 1%" to push him a little /// further towards a ghost. Dropping it was still the release this effect needed, because it /// is what allowed the shader to stay put - see below; the 1% is now a separate, optional /// layer on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it. /// /// The two failed attempts are worth keeping written down, because neither could have been /// predicted from the decompiler and each was settled by one log line: /// /// 1. "Unlit/Transparent Greyscale" (what GameManager uses for greyed-out item icons) has /// NO _Color - nowhere to put an alpha. Traders came out opaque, and because that shader /// is built for NGUI atlases rather than skinned meshes, the user saw them "в негативе". /// 2. "Unlit/Transparent Colored" has no _Color either: `has _Color: False` in the log. /// NGUI tints through VERTEX colours, not a material property, and a character mesh has /// none - so that whole family of shaders was always a dead end here. /// /// WITHOUT THE ALPHA REQUIREMENT THE SHADER DOES NOT HAVE TO BE REPLACED AT ALL, and that is /// strictly better than anything above: the material is cloned with its own shader intact and /// only its albedo texture is swapped for a desaturated copy. Lighting, normal maps, specular, /// skinning - all still the game's own. The trader looks exactly like himself, in black and /// white. Nothing can go "negative", because nothing but the pixels changes. /// /// THE ALBEDO IS FOUND, NOT ASSUMED - this is what the earlier runs bought us. The first /// attempt reached for _MainTex and produced an untextured silhouette, because traders are /// drawn by TWO different shaders and only one of them uses that name. The probe below walks /// the shader's declared properties instead, and the log then said exactly what they are: /// /// shader 'Game/Character' texture properties: _Albedo=set, _Normal=set, _RMOE=set, /// _texcoord=empty; chosen albedo: HD_Rekt 4096x4096 /// shader 'Game/Autodesk' texture properties: _MainTex=set, _BumpMap=set, ...; /// chosen albedo: HD_Rekt_Hair 2048x2048 /// /// So the body uses _Albedo and the hair uses _MainTex - which is precisely why the name is /// discovered rather than hard-coded, and why the probe stays in: Jen is assembled by a /// different character system than Rekt (AvatarSDCSController vs AvatarNpcController in /// entityclasses.xml) and may well introduce a third shader. /// /// GREYSCALE IS DONE TO THE TEXTURE, via a RenderTexture round trip. The round trip is the /// point: game textures are compressed with isReadable=false, so GetPixels on the original /// throws - blitting into an ARGB32 RenderTexture and reading THAT back is the standard way to /// reach pixels the CPU was never handed. Luma weights 0.299/0.587/0.114 rather than a flat /// average, so it reads like a black-and-white photograph instead of a muddy one. Cached per /// source texture: these are 4096x4096, and a readback per renderer per sweep would be /// indefensible. /// /// WHY A TICK AND NOT A SPAWN HOOK. Traders are streamed in on approach ("force spawning /// pending entity npcTraderRekt" appeared ~4 minutes after the world loaded), and Jen is built /// at runtime, so her renderers do not all exist when the entity is added to the world. /// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here - /// avoids guessing at the right moment inside someone else's character pipeline. A trader with /// no renderers yet is simply not marked done and is picked up on the next sweep. /// public static class GhostTraderPatch { /// Seconds between sweeps. public const float SweepInterval = 2f; /// 1 = solid. 0.99 is the "буквально 1%" the user asked for on 2026-09-13 after /// seeing the black-and-white traders: a hint of not-quite-there rather than a ghost. /// Deliberately close to opaque for a second reason too - see ApplyTransparency, which /// keeps depth writing on precisely because a nearly-solid character can afford to. public const float GhostAlpha = 0.99f; /// Colour properties that might carry an alpha, best first. public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" }; /// Entity ids already converted. Cleared when the world unloads. public static readonly HashSet Ghosted = new HashSet(); /// Source shader names already described in the log, so the probe says each /// distinct thing once rather than once per trader per part. public static readonly HashSet ProbedShaders = new HashSet(); /// Desaturated copies, keyed by the texture they came from. public static readonly Dictionary GreyTextures = new Dictionary(); /// Property names that look like an albedo, best first. Confirmed in game: /// "Game/Character" uses _Albedo, "Game/Autodesk" uses _MainTex. Anything else falls /// through to "the first texture property that has something in it". public static readonly string[] AlbedoNameHints = { "_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap", "_Diffuse", "_ColorMap", "_MainTexture", "_Texture" }; public static float timer; /// Called from ModEntry.InitMod. public static void Init() { ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate); ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown); } public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data) { Ghosted.Clear(); GreyTextures.Clear(); timer = 0f; } public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data) { timer += Time.deltaTime; if (timer < SweepInterval) { return; } timer = 0f; World world = GameManager.Instance != null ? GameManager.Instance.World : null; if (world == null || world.EntityAlives == null) { return; } for (int i = 0; i < world.EntityAlives.Count; i++) { EntityAlive entity = world.EntityAlives[i]; if (!(entity is EntityTrader trader) || trader.IsDead()) { continue; } if (Ghosted.Contains(trader.entityId)) { continue; } if (ApplyGreyscale(trader)) { Ghosted.Add(trader.entityId); } } } /// False when there is nothing to work on yet (model not built), so the caller /// leaves this trader unmarked and tries again on the next sweep. public static bool ApplyGreyscale(EntityTrader _trader) { Renderer[] renderers = _trader.GetComponentsInChildren(true); if (renderers == null || renderers.Length == 0) { return false; } int converted = 0; foreach (Renderer renderer in renderers) { if (renderer == null || renderer is ParticleSystemRenderer) { continue; } Material[] sources = renderer.sharedMaterials; if (sources == null || sources.Length == 0) { continue; } Material[] greys = new Material[sources.Length]; bool anyChanged = false; for (int i = 0; i < sources.Length; i++) { greys[i] = MakeGreyMaterial(sources[i], ref anyChanged); } if (anyChanged) { renderer.materials = greys; converted++; } } Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName + " (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length + " renderer(s) desaturated"); return true; } /// Clone of the source material - SAME shader, same everything - with only its /// albedo replaced by a black-and-white copy. public static Material MakeGreyMaterial(Material _source, ref bool _changed) { if (_source == null) { return null; } ProbeShaderOnce(_source); Material grey = new Material(_source); if (ApplyTransparency(grey)) { _changed = true; } string albedoProperty = FindAlbedoProperty(_source); if (albedoProperty == null) { // Nothing to desaturate on this material; hand back the clone unchanged rather // than dropping the renderer's material entirely. return grey; } Texture2D desaturated = Desaturate(_source.GetTexture(albedoProperty)); if (desaturated == null) { return grey; } grey.SetTexture(albedoProperty, desaturated); _changed = true; return grey; } /// /// Makes the material blend instead of being drawn solid, then dials its alpha down by the /// requested sliver. Two levers, both conditional, because the traders' own shaders are /// game-specific and nothing about them can be assumed: /// /// - A COLOUR with an alpha channel (_Color and friends). This is the only thing that /// actually sets the opacity. /// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is /// handed, so without this the first lever does nothing visible - the same wall the /// mod's first transparency attempt hit back on 2026-08-28 with the summoned pets. /// The recipe is the game's own: MeshDescription.SetupMaterialWithBlendMode writes /// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword. /// /// _ZWrite IS LEFT ALONE ON PURPOSE. The usual recipe switches depth writing off, which is /// right for glass and wrong for a person: without it every part of the model shows through /// every other part and the trader turns into a soup of overlapping limbs. At 99% opacity /// there is nothing to see through anyway, so keeping depth writing costs nothing visible /// and avoids that entirely. /// /// Whatever is missing is reported by the probe rather than silently skipped - if neither /// lever exists on these shaders, the traders stay solid black-and-white and the log says /// why. /// public static bool ApplyTransparency(Material _material) { bool touched = false; foreach (string hint in TintNameHints) { if (!_material.HasProperty(hint)) { continue; } Color tint = _material.GetColor(hint); tint.a *= GhostAlpha; _material.SetColor(hint, tint); touched = true; break; } if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend")) { _material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha); _material.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha); _material.EnableKeyword("_ALPHABLEND_ON"); _material.renderQueue = (int)RenderQueue.Transparent; touched = true; } return touched; } /// Name of the texture property holding this material's albedo, or null. Walks /// the shader's declared properties rather than assuming a name - the body and the hair of /// the same trader disagree about it. public static string FindAlbedoProperty(Material _source) { Shader shader = _source.shader; if (shader == null) { return null; } foreach (string hint in AlbedoNameHints) { if (_source.HasProperty(hint) && _source.GetTexture(hint) != null) { return hint; } } int count = shader.GetPropertyCount(); for (int i = 0; i < count; i++) { if (shader.GetPropertyType(i) != ShaderPropertyType.Texture) { continue; } string name = shader.GetPropertyName(i); if (_source.GetTexture(name) != null) { return name; } } return null; } /// Black-and-white copy of a texture. See the class comment for why this goes /// through a RenderTexture instead of reading the source directly. public static Texture2D Desaturate(Texture _source) { if (_source == null) { return null; } if (GreyTextures.TryGetValue(_source, out Texture2D cached)) { return cached; } Texture2D grey = null; RenderTexture rt = null; RenderTexture previous = RenderTexture.active; try { rt = RenderTexture.GetTemporary(_source.width, _source.height, 0, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); Graphics.Blit(_source, rt); RenderTexture.active = rt; grey = new Texture2D(_source.width, _source.height, TextureFormat.RGBA32, false); grey.ReadPixels(new Rect(0f, 0f, _source.width, _source.height), 0, 0); Color32[] pixels = grey.GetPixels32(); for (int i = 0; i < pixels.Length; i++) { Color32 p = pixels[i]; byte luma = (byte)((p.r * 299 + p.g * 587 + p.b * 114) / 1000); p.r = luma; p.g = luma; p.b = luma; pixels[i] = p; } grey.SetPixels32(pixels); grey.Apply(false, false); } catch (System.Exception e) { Debug.LogError("[NecromancerTome] GhostTraderPatch: could not desaturate '" + _source.name + "': " + e.Message); grey = null; } finally { RenderTexture.active = previous; if (rt != null) { RenderTexture.ReleaseTemporary(rt); } } // Cached even on failure (as null) so an unreadable texture is not retried per trader. GreyTextures[_source] = grey; return grey; } /// Says, once per distinct source shader, which texture properties it has and /// which carry anything. This is what told us the body uses _Albedo and the hair _MainTex; /// it stays in because the next trader built by a different character system will announce /// itself the same way. public static void ProbeShaderOnce(Material _source) { Shader shader = _source.shader; string shaderName = shader != null ? shader.name : ""; if (!ProbedShaders.Add(shaderName) || shader == null) { return; } // EVERY property, not just the textures. The texture-only version answered the // "where is the albedo" question; this one has to answer "is there anything here that // can make it transparent at all", and that lives among the floats and colours. System.Text.StringBuilder sb = new System.Text.StringBuilder(); int count = shader.GetPropertyCount(); for (int i = 0; i < count; i++) { string name = shader.GetPropertyName(i); ShaderPropertyType type = shader.GetPropertyType(i); sb.Append(sb.Length > 0 ? ", " : "").Append(name).Append(':').Append(type); if (type == ShaderPropertyType.Texture) { sb.Append(_source.GetTexture(name) != null ? "=set" : "=empty"); } } string chosen = FindAlbedoProperty(_source); Texture chosenTexture = chosen != null ? _source.GetTexture(chosen) : null; string tint = ""; foreach (string hint in TintNameHints) { if (_source.HasProperty(hint)) { tint = hint + " (alpha " + _source.GetColor(hint).a.ToString("0.###") + ")"; break; } } bool canBlend = _source.HasProperty("_SrcBlend") && _source.HasProperty("_DstBlend"); Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' properties: " + (sb.Length > 0 ? sb.ToString() : "")); Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "") + " -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "") + "; tint property: " + tint + "; blend-mode properties present: " + canBlend); } } }