diff --git a/HarmonySrc/GhostTraderCommand.cs b/HarmonySrc/GhostTraderCommand.cs new file mode 100644 index 0000000..5714664 --- /dev/null +++ b/HarmonySrc/GhostTraderCommand.cs @@ -0,0 +1,174 @@ +using System.Collections.Generic; +using UnityEngine.Scripting; + +namespace NecromancerTome +{ + /// + /// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild + /// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную + /// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken + /// model" - can only be judged by looking at him, and every step of that hunt otherwise costs + /// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because + /// traders are streamed in on approach. This collapses the loop to one line in the console. + /// + /// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit + /// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the + /// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha + /// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the + /// unit and prints both back every time. + /// + /// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right + /// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships. + /// A settings file would just be a second place for the answer to hide. + /// + /// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes + /// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that + /// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract + /// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either: + /// GameManager calls ModManager.LoadMods() three lines before RegisterCommands(). + /// + /// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would + /// otherwise run where there is nothing to look at; the flag makes the server bounce it back + /// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the + /// materials and the eyes are. In single player it changes nothing. + /// + [Preserve] + public class ConsoleCmdNecroGhost : ConsoleCmdAbstract + { + public override bool IsExecuteOnClient => true; + + public override bool AllowedInMainMenu => false; + + public override string[] getCommands() + { + return new string[] { "necroghost", "necrotrader" }; + } + + public override string getDescription() + { + return "Necromancer's Tome: how transparent the ghost traders are, in percent."; + } + + public override string getHelp() + { + return "necroghost - show the current value and mode\n" + + "necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" + + " 30 = clearly a ghost; past ~30 he stops reading as a body)\n" + + "necroghost blend - fade the body by blending (re-shades it; smooth)\n" + + "necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" + + "necroghost reset - back to the built-in default value and mode\n" + + "\n" + + "Applies to traders already in the world, immediately - walk up to one first and\n" + + "watch him while you type. Not saved: tell the mod author what you settled on.\n" + + "\n" + + "THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" + + "game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" + + "re-shades the body onto the hair's shader, which has a transparent pass, at the\n" + + "cost of the character shader's own lighting. The hair fades the same way either\n" + + "way, so it is the body you compare.\n" + + "\n" + + "If he comes apart instead of fading - teeth through the cheek, an arm through the\n" + + "chest - that is not this number, that is depth writing, and no value here will fix it."; + } + + public override void Execute(List _params, CommandSenderInfo _senderInfo) + { + if (_params.Count == 0) + { + Report("Ghost traders"); + return; + } + + string argument = _params[0].Trim(); + if (argument.EqualsCaseInsensitive("reset")) + { + GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha; + SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset"); + return; + } + + if (argument.EqualsCaseInsensitive("blend")) + { + SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode"); + return; + } + + if (argument.EqualsCaseInsensitive("dither")) + { + SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode"); + return; + } + + if (!TryParsePercent(argument, out float percent)) + { + SingletonMonoBehaviour.Instance.Output( + "necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " + + "Try 'necroghost 10', or 'help necroghost'."); + return; + } + + if (percent < 0f || percent > 100f) + { + SingletonMonoBehaviour.Instance.Output( + "necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible."); + return; + } + + GhostTraderPatch.GhostAlpha = 1f - percent / 100f; + Report("Set"); + + // Said only when asked for, and only once the value is actually past the point where + // the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha. + if (GhostTraderPatch.GhostAlpha < 0.7f) + { + SingletonMonoBehaviour.Instance.Output( + " (past ~30% the silhouette stops reading as a solid body at all, which looks like " + + "a broken model for a different reason than depth writing does)"); + } + } + + /// Switches how the body is faded and rebuilds the traders already standing, which + /// is the expensive path - the materials have to be built again from the originals, since + /// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here. + /// Saying so out loud matters: this is the one thing in the command that is not free, and + /// flipping modes back and forth while hunting a value is the obvious way to use it. + public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix) + { + bool changed = GhostTraderPatch.BodyMode != _mode; + GhostTraderPatch.BodyMode = _mode; + int rebuilt = changed ? GhostTraderPatch.Reapply() : 0; + Report(_prefix); + if (changed && rebuilt > 0) + { + SingletonMonoBehaviour.Instance.Output( + " (" + rebuilt + " renderer(s) rebuilt from their original materials)"); + } + } + + /// Current value plus what it actually reached, in both units, and which way the + /// body is being faded. The count is the half that answers "did it do anything": 0 + /// materials means no trader has been converted yet - they stream in on approach - not + /// that the number was refused. + public static void Report(string _prefix) + { + float alpha = GhostTraderPatch.GhostAlpha; + int applied = GhostTraderPatch.Retint(); + SingletonMonoBehaviour.Instance.Output( + _prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " + + alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode + + ", applied to " + applied + " live material(s) across " + + GhostTraderPatch.Ghosted.Count + " trader(s) converted this session."); + } + + /// Percent out of what the user typed. StringParsers is the game's own parser and + /// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator, + /// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as + /// 125 and the trader would vanish. The comma is turned into a point before it gets there. + /// A trailing '%' is accepted because it is the obvious thing to type. + public static bool TryParsePercent(string _argument, out float _percent) + { + string text = _argument.Replace(',', '.').TrimEnd('%').Trim(); + return StringParsers.TryParseFloat(text, out _percent); + } + } +} diff --git a/HarmonySrc/GhostTraderPatch.cs b/HarmonySrc/GhostTraderPatch.cs index 2b76d53..336b766 100644 --- a/HarmonySrc/GhostTraderPatch.cs +++ b/HarmonySrc/GhostTraderPatch.cs @@ -10,12 +10,13 @@ namespace NecromancerTome /// торговцев убираем совсем"). 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. + /// TRANSPARENCY WAS DROPPED, THEN ASKED BACK FOR AT A SLIVER, THEN RAISED TO 10%. The user + /// first said "убираем совсем", confirmed the result ("торговец стал непрозрачным и + /// полностью чёрно-белым, как и требовалось"), then asked for "лёгкую прозрачность, + /// буквально 1%", and on 2026-09-14 for 10% - "чтобы он выглядел как призрак, а не как + /// сломанная модель". Dropping it was still the release this effect needed, because it + /// is what allowed the shader to stay put - see below; the alpha is 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: @@ -56,6 +57,10 @@ namespace NecromancerTome /// source texture: these are 4096x4096, and a readback per renderer per sweep would be /// indefensible. /// + /// THE ALPHA IS TURNED FROM THE CONSOLE, not from this file: `necroghost `, in + /// GhostTraderCommand.cs. A rendering balance can only be judged by looking at it, and a + /// rebuild-restart-walk-to-a-trader cycle per step is not a way to look at anything. + /// /// 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. @@ -68,15 +73,125 @@ namespace NecromancerTome /// 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; + /// What a fresh game boots with. 1 = solid; 0.3 is what the hunt actually landed + /// on - the user set 70% transparency in game on 2026-09-14 once the body was blending + /// instead of dithering, and kept it. THE VALUE FOUND IN GAME BELONGS HERE: the console + /// command turns GhostAlpha for the session only and deliberately persists nothing, so a + /// number worth keeping has to be written into this line. + /// + /// The road here was 1% -> 10% -> 70%, and the last jump was not a change of taste: at 10% + /// the body was still being dithered, and a dither is only bearable while it is faint. Once + /// it became real blending, far more of it turned out to look right. + public const float DefaultGhostAlpha = 0.3f; - /// Colour properties that might carry an alpha, best first. + /// The opacity actually in use. THIS IS THE ONE NUMBER TO TURN when hunting the + /// balance between "ghost" and "broken model" - everything else in this file is about + /// making the number mean what it says - and `necroghost <percent>` turns it live + /// (GhostTraderCommand.cs), because every step of that hunt otherwise costs a rebuild, a + /// restart and the four-minute walk to a trader. + /// + /// Which way to turn it is decided by WHICH failure you are looking at, and the two look + /// nothing alike: + /// - reads as a solid person, no ghost at all -> lower it (0.85, 0.8). + /// - the world shows through him but he still reads as one body -> this is the target. + /// - you can see his teeth through his cheek, eyes through eyelids, an arm through the + /// chest -> that is the "broken model", and it is NOT this number's fault. It means + /// depth writing came off somewhere; see _ZWrite in ApplyTransparency. Dropping the + /// alpha further only makes it worse. + /// THE OLD "nothing below ~0.7" NOTE WAS WRONG, and it is worth saying why rather than + /// quietly deleting: it was written while the body was still dithering, where a low value + /// means a coarse pattern and the silhouette falls apart early. With the body blending, + /// 0.3 reads as a ghost and holds together - the limit belonged to the technique, not to + /// the eye. Depth writing is what keeps him one body, and it does not care how low the + /// number goes. + public static float GhostAlpha = DefaultGhostAlpha; + + /// One opacity lever this patch installed on a trader, remembered so the alpha + /// can be turned again WITHOUT rebuilding anything. Keeping the base value is the whole + /// point: the live value is always base * GhostAlpha, never "multiply what is there now by + /// the new number" - that would compound (0.9 then 0.8 would land on 0.72) and the second + /// turn of the dial would lie about where it put you. + /// + /// IsColour says which of the two kinds this is - the alpha channel of a colour, or a + /// plain float - because a trader needs BOTH and they are not interchangeable: the body + /// only has the float (_Fade) and the hair only has the colour (_Color). One material can + /// therefore contribute one entry, and one trader contributes several of both kinds. + public struct GhostMaterial + { + public Material Material; + public string Property; + public bool IsColour; + public float BaseValue; + } + + /// A renderer this patch has taken over, with the materials it had before. The + /// originals are the reason a mode can be switched at all: after the first pass + /// renderer.sharedMaterials hands back OUR clones, so rebuilding from what is currently + /// on the renderer would re-shade an already re-shaded material and there would be no way + /// back to the body's real one. Rebuilding always starts here instead. + public struct GhostRenderer + { + public Renderer Renderer; + public Material[] Originals; + } + + /// Every renderer taken over, in the order it was found. Pruned of destroyed + /// renderers as they are walked; dropped wholesale when the world unloads. + public static readonly List Converted = new List(); + + /// Every material installed on a live trader that has somewhere to put an alpha. + /// Retint walks this instead of redoing the work: re-running the sweep would re-clone the + /// materials and, far worse, hand the 4096x4096 desaturation another readback per trader. + /// Entries go stale when the game destroys the material with its trader - Retint prunes + /// those, and WorldShuttingDown drops the lot. + public static readonly List TintedMaterials = new List(); + + /// Colour properties that might carry an alpha, best first. Confirmed in game: + /// the hair/beard shader "Game/Autodesk" has _Color; the BODY shader "Game/Character" has + /// no colour property at all - see FadeNameHints. public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" }; + /// How the BODY is made see-through. The two are not two settings of one thing, + /// they are two different renderers' worth of behaviour, and only a look in game can pick + /// between them - which is why both stay reachable from the console. + /// + /// Dither is the game's own _Fade, and on 2026-09-14 the user reported what it actually + /// looks like: "тело гаснет, но идёт мелкой сеточкой". That is screen-door transparency - + /// the shader is not blending anything, it is THROWING PIXELS AWAY in a fixed pattern. + /// It is what an opaque-queue character shader can do without a transparent pass, and no + /// value of GhostAlpha turns a dropped pixel into a translucent one. + /// + /// Blend re-shades the body onto the shader the HAIR is already drawn with, which does + /// have a transparent pass. The existence proof is on the same model in the same frame: + /// the beard has been fading smoothly this whole time while the body was dithering. + public enum BodyOpacityMode + { + Dither, + Blend + } + + /// Blend by default: dither has been looked at and rejected. `necroghost blend` + /// and `necroghost dither` switch it live - see GhostTraderCommand.cs. + public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend; + + /// The hair's shader, kept once it is found on a real trader. Taken off the model + /// rather than through Shader.Find so it is the exact shader already proven to work in + /// this scene - name lookups can miss a stripped or renamed shader and hand back null, + /// and the mod has been down that road twice already with the Unlit family. + public static Shader BlendShader; + + /// Normal-map properties, best first. "Game/Character" calls it _Normal, the hair + /// shader wants _BumpMap - the same disagreement the albedo has. + public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" }; + + /// Float properties that fade the whole material out, best first. This is the + /// body's only lever and the game's own: EntityModel.SetFade writes exactly "_Fade", and + /// guards it with `material.HasProperty("_Fade") && shader.name.Contains("Game/Character")` + /// - the same shader our traders' bodies are drawn with. EntityAlive.Update drives it from + /// renderFade, and SetVisible(renderFade > 0.01f) right next to it settles the direction + /// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha. + public static readonly string[] FadeNameHints = { "_Fade" }; + /// Entity ids already converted. Cleared when the world unloads. public static readonly HashSet Ghosted = new HashSet(); @@ -109,6 +224,9 @@ namespace NecromancerTome { Ghosted.Clear(); GreyTextures.Clear(); + TintedMaterials.Clear(); + Converted.Clear(); + BlendShader = null; timer = 0f; } @@ -155,7 +273,12 @@ namespace NecromancerTome return false; } + // Before anything is touched, because the body's replacement shader is found on the + // trader's OWN hair and the hair is not guaranteed to come first in this array. + FindBlendShader(renderers); + int converted = 0; + int leversBefore = TintedMaterials.Count; foreach (Renderer renderer in renderers) { if (renderer == null || renderer is ParticleSystemRenderer) @@ -169,26 +292,266 @@ namespace NecromancerTome continue; } - Material[] greys = new Material[sources.Length]; - bool anyChanged = false; - for (int i = 0; i < sources.Length; i++) + Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources }); + if (Convert(renderer, sources)) { - greys[i] = MakeGreyMaterial(sources[i], ref anyChanged); - } - if (anyChanged) - { - renderer.materials = greys; converted++; } } + // The lever count is the half that answers "will the console command reach him": + // desaturation and opacity come from different properties, and the body had the first + // without the second until 2026-09-14. Fewer levers than converted renderers means + // some part of this trader can only ever be black-and-white, never transparent. Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName + - " (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length + " renderer(s) desaturated"); + " (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length + + " renderer(s) desaturated, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed"); return true; } - /// Clone of the source material - SAME shader, same everything - with only its - /// albedo replaced by a black-and-white copy. + /// Builds and installs this renderer's ghost materials from the ORIGINALS it was + /// handed. True when anything was actually changed - a renderer nothing could be done to + /// keeps the materials it has rather than being handed a half-built array. + public static bool Convert(Renderer _renderer, Material[] _sources) + { + Material[] ghosts = new Material[_sources.Length]; + bool anyChanged = false; + for (int i = 0; i < _sources.Length; i++) + { + ghosts[i] = MakeGreyMaterial(_sources[i], ref anyChanged); + } + if (anyChanged) + { + _renderer.materials = ghosts; + } + return anyChanged; + } + + /// Rebuilds every trader already converted, from their original materials, under + /// whatever BodyMode is set now. This is the expensive path and exists only for the mode + /// switch; changing the alpha alone goes through Retint, which touches no materials at + /// all. Even here the 4096x4096 desaturation is not redone - GreyTextures is keyed by the + /// source texture, and the source is the same original every time. + public static int Reapply() + { + TintedMaterials.Clear(); + int rebuilt = 0; + for (int i = Converted.Count - 1; i >= 0; i--) + { + GhostRenderer entry = Converted[i]; + if (entry.Renderer == null) + { + Converted.RemoveAt(i); + continue; + } + if (Convert(entry.Renderer, entry.Originals)) + { + rebuilt++; + } + } + return rebuilt; + } + + /// Remembers the first shader on this model that can actually blend - it needs + /// both a colour to put an alpha in and a blend mode to honour it. On a trader that is the + /// hair's shader. Falls back to looking the name up only if the model somehow has no such + /// material, and says so either way, because a null here silently disables the whole Blend + /// mode and leaves the body dithering with no explanation. + public static Shader FindBlendShader(Renderer[] _renderers) + { + if (BlendShader != null) + { + return BlendShader; + } + + foreach (Renderer renderer in _renderers) + { + if (renderer == null || renderer is ParticleSystemRenderer) + { + continue; + } + Material[] materials = renderer.sharedMaterials; + if (materials == null) + { + continue; + } + foreach (Material material in materials) + { + if (material == null || material.shader == null || !CanBlendInPlace(material)) + { + continue; + } + BlendShader = material.shader; + Debug.Log("[NecromancerTome] GhostTraderPatch: blend shader taken off the model: '" + + BlendShader.name + "'"); + return BlendShader; + } + } + + BlendShader = Shader.Find("Game/Autodesk"); + Debug.Log("[NecromancerTome] GhostTraderPatch: no blend-capable material on this model; " + + "the shader was looked up by name instead and came back " + + (BlendShader != null ? BlendShader.name : "NULL - the body stays dithered")); + return BlendShader; + } + + /// Whether this material can be made see-through where it stands: somewhere to + /// put an alpha, and a blend mode to make the alpha mean something. The body fails both + /// halves and the hair passes both - which is the whole difference between them, and the + /// reason this is a capability test rather than a check on the shader's name. + public static bool CanBlendInPlace(Material _material) + { + if (!_material.HasProperty("_SrcBlend") || !_material.HasProperty("_DstBlend")) + { + return false; + } + foreach (string hint in TintNameHints) + { + if (_material.HasProperty(hint)) + { + return true; + } + } + return false; + } + + /// + /// The body, re-shaded onto the hair's shader so it can blend the way the hair does, with + /// the black-and-white albedo it would have had anyway. + /// + /// ONLY WHAT IS UNDERSTOOD IS CARRIED OVER - albedo and normal map. The body's third map, + /// _RMOE, is a packed roughness/metallic/occlusion/emission texture, and the destination + /// shader's _MetallicGlossMap expects metallic in R and smoothness in A: the channels do + /// not line up, and wiring them by hope is exactly how a character comes out looking like + /// wet plastic or a negative. It is left empty and the two floats are set by hand instead + /// - no metal, barely any gloss, which is what a dead man in black and white should be. + /// + /// This is a real trade and worth stating plainly: the body loses the character shader's + /// own lighting response and gets standard lighting instead. In exchange it can actually + /// be translucent rather than dithered. If it reads wrong in game, `necroghost dither` is + /// one line away and nothing has to be rebuilt. + /// + public static Material MakeBlendMaterial(Material _source, Texture2D _albedo) + { + Material blend = new Material(BlendShader); + blend.SetTexture("_MainTex", _albedo); + + Texture normal = FindTexture(_source, NormalNameHints); + if (normal != null && blend.HasProperty("_BumpMap")) + { + blend.SetTexture("_BumpMap", normal); + } + MakeMatte(blend); + // Standard's own "Fade" setting. The shader branches on keywords rather than on this + // float, and ApplyTransparency sets those - but a material inspected later with its + // mode still reading "Opaque" is a trap for whoever looks next. + if (blend.HasProperty("_Mode")) + { + blend.SetFloat("_Mode", 2f); + } + + ApplyTransparency(blend); + return blend; + } + + /// + /// Takes the shine off (user request 2026-09-14, once the body was blending properly: + /// "слишком оно бликует, я бы вообще сделал матовым"). Applied to EVERY ghost material, not just the + /// re-shaded body: the hair is drawn by the same shader and was keeping its own shine, and + /// a matte body under a glossy beard would have read worse than either. + /// + /// THREE THINGS, BECAUSE THEY FAIL DIFFERENTLY: + /// + /// - _Glossiness (smoothness) to 0. This one is plain shader maths and always works - + /// the surface becomes maximally rough, and a rough surface has no tight highlight. + /// It is the load-bearing half of this method. + /// - _SpecularHighlights / _GlossyReflections to 0 WITH their keywords. These are the + /// Standard shader's [ToggleOff] pair: the float is only what the inspector shows, the + /// shader branches on _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, so setting the + /// float alone does nothing at all. They kill the direct highlight and the reflection + /// probe contribution outright. + /// - The gloss MAPS, cleared if anything is in them. A map wins over the float: leave + /// one in place and _Glossiness is ignored, which is the silent way for all of this to + /// appear to do nothing. They are empty on these traders today - this is for the next + /// model that is not. + /// + /// HONEST LIMIT: those two keywords are `shader_feature`, which Unity strips at build time + /// if no material in the game ships with them set. If they were stripped, EnableKeyword is + /// a no-op and only the roughness half lands. That is why the roughness half is not + /// treated as a fallback but as the main lever - and why this is not attempted at all on + /// the body's own "Game/Character" shader, which has none of these properties: its shine + /// lives inside the packed _RMOE texture and would have to be repainted, not switched off. + /// + public static void MakeMatte(Material _material) + { + if (_material.HasProperty("_Glossiness")) + { + _material.SetFloat("_Glossiness", 0f); + } + if (_material.HasProperty("_GlossMapScale")) + { + _material.SetFloat("_GlossMapScale", 0f); + } + if (_material.HasProperty("_Metallic")) + { + _material.SetFloat("_Metallic", 0f); + } + if (_material.HasProperty("_SpecularHighlights")) + { + _material.SetFloat("_SpecularHighlights", 0f); + _material.EnableKeyword("_SPECULARHIGHLIGHTS_OFF"); + } + if (_material.HasProperty("_GlossyReflections")) + { + _material.SetFloat("_GlossyReflections", 0f); + _material.EnableKeyword("_GLOSSYREFLECTIONS_OFF"); + } + ClearTexture(_material, "_SpecGlossMap"); + ClearTexture(_material, "_MetallicGlossMap"); + } + + /// Empties a texture slot if this material has one and it is not already empty. + /// + public static void ClearTexture(Material _material, string _property) + { + if (_material.HasProperty(_property) && _material.GetTexture(_property) != null) + { + _material.SetTexture(_property, null); + } + } + + /// First texture among these property names that this material actually has + /// something in. + public static Texture FindTexture(Material _source, string[] _hints) + { + foreach (string hint in _hints) + { + if (_source.HasProperty(hint)) + { + Texture texture = _source.GetTexture(hint); + if (texture != null) + { + return texture; + } + } + } + return null; + } + + /// + /// This material's ghost version. Two shapes, and which one it takes is decided by what + /// the material can do rather than by what it is called: + /// + /// - CAN be made see-through where it stands (the hair) -> a clone with the SAME shader + /// and only its albedo swapped for a black-and-white copy. Lighting, normal maps, + /// specular, skinning - all still the game's own. + /// - CANNOT (the body: no colour, no blend mode) -> under BodyMode.Blend it is re-shaded + /// onto the shader the hair uses, which can. Under BodyMode.Dither it takes the clone + /// path anyway and gets the _Fade lever, which works but throws pixels away. + /// + /// The albedo is desaturated first either way, because both shapes need it and the result + /// is cached per source texture - the re-shaded body is not paying for a second readback. + /// public static Material MakeGreyMaterial(Material _source, ref bool _changed) { if (_source == null) @@ -198,48 +561,83 @@ namespace NecromancerTome ProbeShaderOnce(_source); + string albedoProperty = FindAlbedoProperty(_source); + Texture2D desaturated = albedoProperty != null + ? Desaturate(_source.GetTexture(albedoProperty)) + : null; + + // Re-shading without an albedo to hand over would produce an untextured silhouette - + // the exact failure this patch already shipped once, on 2026-09-13. If the texture + // could not be read, the body keeps its own shader and stays dithered instead, and + // the lever count in the log is what says so. + if (BodyMode == BodyOpacityMode.Blend && BlendShader != null && desaturated != null && + !CanBlendInPlace(_source)) + { + _changed = true; + return MakeBlendMaterial(_source, desaturated); + } + Material grey = new Material(_source); + MakeMatte(grey); if (ApplyTransparency(grey)) { _changed = true; } - string albedoProperty = FindAlbedoProperty(_source); - if (albedoProperty == null) + if (desaturated == 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: + /// Dials this material's opacity down to GhostAlpha. THREE levers, each conditional, + /// because a trader is drawn by two different shaders that have almost nothing in common - + /// and the first version of this method only had the levers the hair happens to own, which + /// is why the user reported on 2026-09-14 that the console command "управляет только + /// прозрачностью бороды". The probe had already printed the reason, one line each: /// - /// - A COLOUR with an alpha channel (_Color and friends). This is the only thing that - /// actually sets the opacity. + /// shader 'Game/Character' - albedo: _Albedo -> HD_Rekt 4096x4096; + /// tint property: ; blend-mode properties present: False + /// shader 'Game/Autodesk' - albedo: _MainTex -> HD_Rekt_Hair 2048x2048; + /// tint property: _Color (alpha 1); blend-mode properties present: True + /// + /// The BODY has no colour property and no blend mode at all. Two of the three levers below + /// simply do not exist on it, and no value of GhostAlpha was ever going to reach it. + /// + /// - A COLOUR with an alpha channel (_Color and friends). The hair's lever; the body + /// does not have one. + /// - THE FADE FLOAT (_Fade). The body's lever, and the game's own: EntityModel.SetFade + /// writes this exact property and guards it with a check for the "Game/Character" + /// shader by name, so this is not a property we found and hoped about - it is the one + /// the engine itself fades these very models with. Nothing else in the body's property + /// list can carry an opacity: the rest is _Albedo/_Normal/_RMOE, an alpha-CUTOUT + /// cluster (_AlphaCut, _AlphaCutoff, _SoftAlphaCutoff, _AlphaSoftness - a dissolve, + /// which punches holes rather than making glass), _EmissiveColor, and flags. /// - 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 + /// handed, so without this the COLOUR 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. + /// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword. The _Fade + /// lever needs none of it - the shader does its own fading internally, which is + /// exactly why it exists. /// - /// _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. + /// _ZWrite IS LEFT ALONE ON PURPOSE, AND AT 10% THAT IS THE WHOLE BALANCE. The usual + /// transparency recipe switches depth writing off, which is right for glass and wrong for + /// a person: without it every surface of the model blends over every other one, and the + /// trader becomes teeth through cheeks, eyeballs through eyelids, the far arm through the + /// chest - exactly the "сломанная модель" the user does not want. With it on, the depth + /// test keeps only the nearest surface and that one surface blends with the WORLD behind + /// him. So he goes see-through without coming apart: a ghost, not a mess. + /// + /// At 99% this cost nothing because there was nothing to see through; at 90% it is the + /// reason the effect is usable at all. Note that it is not set here either way - these + /// shaders write depth by default, and leaving the property untouched is what keeps it. /// /// 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 @@ -256,8 +654,43 @@ namespace NecromancerTome continue; } Color tint = _material.GetColor(hint); - tint.a *= GhostAlpha; + float baseAlpha = tint.a; + tint.a = baseAlpha * GhostAlpha; _material.SetColor(hint, tint); + TintedMaterials.Add(new GhostMaterial + { + Material = _material, + Property = hint, + IsColour = true, + BaseValue = baseAlpha + }); + touched = true; + break; + } + + foreach (string hint in FadeNameHints) + { + if (!_material.HasProperty(hint)) + { + continue; + } + // A base of 0 would mean the material is already fully faded out, which no + // standing trader is - it means the property is sitting at a default nobody set. + // Multiplying by it would make him vanish outright and no value of GhostAlpha + // could bring him back, so it is read as "solid" instead. + float baseFade = _material.GetFloat(hint); + if (baseFade <= 0f) + { + baseFade = 1f; + } + _material.SetFloat(hint, baseFade * GhostAlpha); + TintedMaterials.Add(new GhostMaterial + { + Material = _material, + Property = hint, + IsColour = false, + BaseValue = baseFade + }); touched = true; break; } @@ -274,6 +707,40 @@ namespace NecromancerTome return touched; } + /// Pushes the current GhostAlpha onto every trader already standing in the world, + /// and returns how many materials took it. Traders that spawn later need nothing from this + /// - ApplyTransparency reads the same field on the way past. + /// + /// Unity's fake null is the reason for the rebuild-in-place rather than a simple loop: a + /// destroyed Material compares equal to null but is still a live list entry, and touching + /// it throws. Walking backwards and dropping those as we go keeps the list from growing + /// across a session of traders streaming in and out. + public static int Retint() + { + int applied = 0; + for (int i = TintedMaterials.Count - 1; i >= 0; i--) + { + GhostMaterial entry = TintedMaterials[i]; + if (entry.Material == null) + { + TintedMaterials.RemoveAt(i); + continue; + } + if (entry.IsColour) + { + Color tint = entry.Material.GetColor(entry.Property); + tint.a = entry.BaseValue * GhostAlpha; + entry.Material.SetColor(entry.Property, tint); + } + else + { + entry.Material.SetFloat(entry.Property, entry.BaseValue * GhostAlpha); + } + applied++; + } + return applied; + } + /// 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. @@ -407,13 +874,23 @@ namespace NecromancerTome break; } } + string fade = ""; + foreach (string hint in FadeNameHints) + { + if (_source.HasProperty(hint)) + { + fade = hint + " (" + _source.GetFloat(hint).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); + "; tint property: " + tint + "; fade property: " + fade + + "; blend-mode properties present: " + canBlend); } } } diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll index b050062..b6cf2eb 100644 Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb index 7d512a7..ab27063 100644 Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ