2 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5 7768541f12 Торговцы-призраки: настоящая прозрачность тела, матовость, консоль
Прозрачность у торговцев управляла только бородой. Причина была напечатана
зондом ещё при первом заходе: у шейдера тела (Game/Character) нет НИ цвета с
альфой, НИ режима смешивания - оба рычага ApplyTransparency существуют только
у шейдера волос (Game/Autodesk). Никакое значение альфы тело бы не тронуло.

Сначала добавлен третий рычаг - _Fade, собственный рычаг игры:
EntityModel.SetFade пишет ровно это свойство и отбирает материалы проверкой
HasProperty("_Fade") && shader.name.Contains("Game/Character"), а стоящий рядом
SetVisible(renderFade > 0.01f) закрывает направление: 1 = сплошной, 0 = исчез.
В игре выяснилось, что шейдер реализует его screen-door дизерингом - "тело
гаснет, но идёт мелкой сеточкой". Рычаг рабочий, но пиксели не смешиваются, а
выбрасываются, и никакое число этого не исправит. Оставлен как режим dither.

РАБОЧЕЕ РЕШЕНИЕ - перешивка тела на шейдер волос, у которого есть прозрачный
проход. Доказательство лежало на той же модели в том же кадре: борода всё это
время гасла плавно. Шейдер берётся С МОДЕЛИ - первый материал, умеющий
смешивать (есть цвет с альфой И режим смешивания); Shader.Find оставлен
запасным путём, потому что мод уже дважды получал null/негатив от семейства
Unlit. Решение принимается по способностям материала, имя шейдера нигде не
зашито. Переносятся только альбедо и нормаль: _RMOE - упакованная
roughness/metallic/occlusion/emission, а _MetallicGlossMap ждёт металл в R и
гладкость в A, каналы не совпадают, и связывание "на авось" даёт мокрый пластик
или негатив. Цена названа прямо: тело теряет собственную световую модель
шейдера персонажа и получает стандартную.

МАТОВОСТЬ по просьбе пользователя - три рычага, ломаются по-разному:
_Glossiness в 0 (чистая математика шейдера, работает всегда - несущая
половина); _SpecularHighlights и _GlossyReflections в 0 ВМЕСТЕ с ключевыми
словами _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, потому что это
[ToggleOff]-пара и запись одного флоата не делает ничего; карты глянца
очищаются, если непусты, - карта побеждает флоат. Оба keyword'а -
shader_feature, Unity вырезает такие на сборке, если их не выставляет ни один
материал игры, поэтому шершавость сделана основным рычагом, а не запасным.
Применяется ко всем призрачным материалам: волосы нарисованы тем же шейдером и
сохраняли свой блеск, а матовое тело под глянцевой бородой читалось бы хуже.

КОНСОЛЬНАЯ КОМАНДА necroghost (алиас necrotrader): проценты прозрачности,
blend/dither, reset. Балансировать рендер можно только глядя на него, а шаг без
команды стоил пересборки, перезапуска и ~4 минут пешком до торговца. Проценты,
а не альфа: это единица, в которой просьба формулировалась, и они идут в разные
стороны. Регистрации нет и не требуется - SdtdConsole.RegisterCommands ищет
через ReflectionHelpers.FindTypesImplementingBase, который обходит
ModManager.GetLoadedAssemblies(), а LoadMods() стоит на три строки раньше
RegisterCommands(). IsExecuteOnClient = true: команда меняет пиксели.

Две мины, обе реальные. Повторное применение не должно умножать: материалы
кэшируются, и sharedMaterials после первого прохода возвращает наши же клоны,
так что наивный повторный свип дал бы 0.9, потом 0.8 = 0.72; запоминается
базовое значение, живое всегда base * GhostAlpha. Запятая: StringParsers не
зависит от локали, но читает "," как разделитель ТЫСЯЧ, и "necroghost 12,5"
молча стало бы 125 - запятая заменяется на точку до парсинга.

DefaultGhostAlpha 0.9 -> 0.3: 70% прозрачности, найденные в игре. Путь был
1% -> 10% -> 70%, и последний скачок не смена вкуса - на 10% тело ещё
дизерилось, а дизеринг терпим лишь пока слаб. Поэтому же переписана заметка
"ниже ~0.7 силуэт разваливается": предел принадлежал технике, а не глазу.

---

Ghost traders: the body really blends, matte finish, console command

The transparency only ever reached the beard. The probe had already printed
why: the body's shader (Game/Character) has NEITHER a colour with an alpha NOR
a blend mode - both of ApplyTransparency's levers exist only on the hair's
shader (Game/Autodesk). No value of the alpha was ever going to touch it.

A third lever was added first - _Fade, the game's own: EntityModel.SetFade
writes exactly that property and guards it with HasProperty("_Fade") &&
shader.name.Contains("Game/Character"), and the SetVisible(renderFade > 0.01f)
sitting next to it settles the direction: 1 = solid, 0 = gone. In game the
shader turned out to implement it as screen-door dithering - "the body fades,
but goes to a fine grid". The lever works, but pixels are thrown away rather
than blended, and no number fixes that. It is kept as the dither mode.

WHAT ACTUALLY WORKS is re-shading the body onto the hair's shader, which does
have a transparent pass. The existence proof was on the same model in the same
frame: the beard had been fading smoothly all along. The shader is taken OFF
THE MODEL - the first material that can blend (a colour with an alpha AND a
blend mode); Shader.Find is kept only as a fallback, because this mod has twice
been handed null or a negative by the Unlit family. The decision is made on
what a material can do; no shader name is hard-coded. Only albedo and normal
are carried over: _RMOE is a packed roughness/metallic/occlusion/emission map
while _MetallicGlossMap wants metallic in R and smoothness in A - the channels
do not line up, and wiring them by hope is how a character ends up looking like
wet plastic or a negative. The trade is stated plainly: the body loses the
character shader's own lighting response and gets standard lighting instead.

MATTE, as requested - three levers that fail differently: _Glossiness to 0
(plain shader maths, always works - the load-bearing half); _SpecularHighlights
and _GlossyReflections to 0 TOGETHER WITH the _SPECULARHIGHLIGHTS_OFF /
_GLOSSYREFLECTIONS_OFF keywords, because they are a [ToggleOff] pair and
setting the float alone does nothing; and the gloss maps cleared if anything is
in them, since a map beats the float. Both keywords are shader_feature, which
Unity strips at build time if no material in the game sets them - which is why
roughness is the main lever and not the fallback. Applied to every ghost
material: the hair uses the same shader and kept its own shine, and a matte
body under a glossy beard would have read worse than either.

CONSOLE COMMAND necroghost (alias necrotrader): transparency in percent,
blend/dither, reset. A rendering balance can only be judged by looking at it,
and without the command each step cost a rebuild, a restart and a four-minute
walk to a trader. Percent rather than alpha: percent is the unit the request
was made in, and the two run in opposite directions. No registration is needed
- SdtdConsole.RegisterCommands goes through
ReflectionHelpers.FindTypesImplementingBase, which walks
ModManager.GetLoadedAssemblies(), and LoadMods() runs three lines before
RegisterCommands(). IsExecuteOnClient = true: the command changes pixels.

Two real traps. Re-applying must not compound: materials are cached and
sharedMaterials hands back our own clones after the first pass, so a naive
second sweep would give 0.9, then 0.8 = 0.72; the base value is remembered and
the live one is always base * GhostAlpha. The comma: StringParsers is
culture-independent but reads "," as a THOUSANDS separator, so "necroghost
12,5" would silently have become 125 - the comma is turned into a point first.

DefaultGhostAlpha 0.9 -> 0.3: the 70% transparency settled on in game. The road
was 1% -> 10% -> 70%, and the last jump was not a change of taste - at 10% the
body was still dithering, and a dither is bearable only while it is faint. For
the same reason the old "below ~0.7 the silhouette falls apart" note was
rewritten: that limit belonged to the technique, not to the eye.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
2026-09-14 15:05:49 +03:00
AlexCubeandClaude Opus 5 9ac575075a Описание файла 1.0.1 для Nexus
Описание КОНКРЕТНОГО файла в разделе Files, отдельно от описания страницы
мода в NEXUS_DESCRIPTION.bbcode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 20:42:06 +03:00
5 changed files with 740 additions and 45 deletions
+174
View File
@@ -0,0 +1,174 @@
using System.Collections.Generic;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// `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.
/// </summary>
[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<string> _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<SdtdConsole>.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<SdtdConsole>.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<SdtdConsole>.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)");
}
}
/// <summary>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.</summary>
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<SdtdConsole>.Instance.Output(
" (" + rebuilt + " renderer(s) rebuilt from their original materials)");
}
}
/// <summary>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.</summary>
public static void Report(string _prefix)
{
float alpha = GhostTraderPatch.GhostAlpha;
int applied = GhostTraderPatch.Retint();
SingletonMonoBehaviour<SdtdConsole>.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.");
}
/// <summary>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.</summary>
public static bool TryParsePercent(string _argument, out float _percent)
{
string text = _argument.Replace(',', '.').TrimEnd('%').Trim();
return StringParsers.TryParseFloat(text, out _percent);
}
}
}
+522 -45
View File
@@ -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 <percent>`, 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
/// <summary>Seconds between sweeps.</summary>
public const float SweepInterval = 2f;
/// <summary>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.</summary>
public const float GhostAlpha = 0.99f;
/// <summary>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.</summary>
public const float DefaultGhostAlpha = 0.3f;
/// <summary>Colour properties that might carry an alpha, best first.</summary>
/// <summary>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 &lt;percent&gt;` 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.</summary>
public static float GhostAlpha = DefaultGhostAlpha;
/// <summary>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.</summary>
public struct GhostMaterial
{
public Material Material;
public string Property;
public bool IsColour;
public float BaseValue;
}
/// <summary>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.</summary>
public struct GhostRenderer
{
public Renderer Renderer;
public Material[] Originals;
}
/// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed
/// renderers as they are walked; dropped wholesale when the world unloads.</summary>
public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>();
/// <summary>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.</summary>
public static readonly List<GhostMaterial> TintedMaterials = new List<GhostMaterial>();
/// <summary>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.</summary>
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
/// <summary>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.</summary>
public enum BodyOpacityMode
{
Dither,
Blend
}
/// <summary>Blend by default: dither has been looked at and rejected. `necroghost blend`
/// and `necroghost dither` switch it live - see GhostTraderCommand.cs.</summary>
public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend;
/// <summary>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.</summary>
public static Shader BlendShader;
/// <summary>Normal-map properties, best first. "Game/Character" calls it _Normal, the hair
/// shader wants _BumpMap - the same disagreement the albedo has.</summary>
public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" };
/// <summary>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.</summary>
public static readonly string[] FadeNameHints = { "_Fade" };
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary>
public static readonly HashSet<int> Ghosted = new HashSet<int>();
@@ -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;
}
/// <summary>Clone of the source material - SAME shader, same everything - with only its
/// albedo replaced by a black-and-white copy.</summary>
/// <summary>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.</summary>
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;
}
/// <summary>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.</summary>
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;
}
/// <summary>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.</summary>
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;
}
/// <summary>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.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>Empties a texture slot if this material has one and it is not already empty.
/// </summary>
public static void ClearTexture(Material _material, string _property)
{
if (_material.HasProperty(_property) && _material.GetTexture(_property) != null)
{
_material.SetTexture(_property, null);
}
}
/// <summary>First texture among these property names that this material actually has
/// something in.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>
/// 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: <none>; 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;
}
/// <summary>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.</summary>
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;
}
/// <summary>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.</summary>
@@ -407,13 +874,23 @@ namespace NecromancerTome
break;
}
}
string fade = "<none>";
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() : "<none>"));
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "<none>") +
" -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "<none>") +
"; tint property: " + tint + "; blend-mode properties present: " + canBlend);
"; tint property: " + tint + "; fade property: " + fade +
"; blend-mode properties present: " + canBlend);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
[size=5][b]Necromancer's Tome 1.0.1[/b][/size]
A bug-fix release on top of 1.0. Same content, one real fix and a few refinements. Drop-in replacement: delete the old [b]NecromancerTome[/b] folder, unpack this one in its place. Your save is fine.
[size=5][b]Fixed[/b][/size]
[b]The Spatial Bracelet no longer loses what you put in it.[/b] Reported by [b]youkia96581[/b] - thank you, this was a real hole and a fair catch. The storage rift was only ever held in memory: it survived death, respawn and relogging, but quitting the game threw it away.
It is now saved the way the game saves your backpack - inside your own player data, written and read in the same moment and the same file. Put things in, quit, come back days later: they are still there. Verified end to end, including a clean exit to the main menu, which was the exact moment things used to disappear.
[b]One honest caveat:[/b] this cannot bring back items lost in 1.0. There was no data on disk to recover - the vault simply was never written anywhere. Anything already lost is gone, and I am sorry about that.
[size=5][b]Changed[/b][/size]
[list]
[*][b]Traders are drawn in black and white.[/b] The necromancer deals with the dead, and the people still willing to trade with him have clearly seen too much. Their own shaders and lighting are untouched - only the colour is gone, plus a sliver of transparency.
[*][b]The Spatial Bracelet has four mod slots.[/b] Empty for now: the mods that fit them come in a later version. Ordinary weapon mods will not go in, and nothing else will take these.
[*][b]The Spatial Bracelet is no longer a parcel in your hand.[/b] It used to borrow a food-crop prefab and looked like a boxed lunch tied with string. Now nothing is drawn at all - just your fist, held the way you hold a block you are about to place.
[*][b]Grave's Repose is much stronger[/b] - cold and heat resistance raised from 5 to 50. With the knife in hand, weather stops being a problem rather than merely being survivable.
[/list]
[size=5][b]Note for existing saves[/b][/size]
A Spatial Bracelet you already own will not gain the four mod slots. The game fixes an item's slot count when the item is created, so an old one keeps the zero it was made with. [b]Craft a new bracelet[/b] and it will have them. Everything else in this release applies to your existing save immediately.
[size=5][b]Requirements[/b][/size]
Unchanged from 1.0: [b]7 Days to Die 3.2[/b], [b]EasyAntiCheat off[/b] (the mod uses Harmony patches), no other mods needed, built and tested single-player, installed per client.
[size=5][b]Shout outs[/b][/size]
[b]The Fun Pimps[/b] - for 7 Days to Die itself, and for the vanilla models, icons and UI templates this mod reuses (the thrown-stone prefab, the book icon behind the summoning tomes, the video player and confirmation dialog the finale is built on).
[b]Andreas Pardeike[/b] - for Harmony. Every runtime mechanic in this mod, this release's storage fix included, is a Harmony patch.
[b]The 7 Days to Die modding community[/b] - for forum posts and open-source mods that answer the questions the XML documentation does not. The official modding API has no reference for the order its events fire in; this release's fix came down to reading that order out of the game's own code, and knowing that was the only way to find out is community knowledge.
[b]youkia96581[/b] - for the bug report that made this version exist.
[b]AI disclosure:[/b] the item icons and the finale artwork are AI-generated. Everything else - the code, the design, the mechanics and the writing - is my own.
Source code and full change history: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
My YouTube channel: [url=https://www.youtube.com/@alexcube]@alexcube[/url]
Binary file not shown.
Binary file not shown.