Files
necromants-tome-7d2d-3-2/HarmonySrc/GhostTraderPatch.cs
T
AlexCubeandClaude Opus 5 20af2bbe6c Торговец снова становится призраком после выгрузки чанка
Баг со стрима: торговец, ставший чёрно-белым и полупрозрачным, наутро снова
обычный человек. Догадка была про обновление ассортимента - не подтвердилась, и
это стоит записать, потому что по часам лавки модель не трогает ВООБЩЕ ничего:
TraderData при сбросе переписывает только PrimaryInventory и lastInventoryUpdate,
а TraderArea.SetClosed ходит по блокам TraderOnOff - двери, замки, BlockLight,
динамик - и до сущности торговца не дотягивается.

Настоящая причина в EntityFactory.CreateEntityOperation.CompleteEntity:
`entity.entityId = ecd.id`, то есть сохранённый айди ВОССТАНАВЛИВАЕТСЯ. Торговцы
стримятся на подходе и точно так же выгружаются, когда игрок уходит на ночь.
Возвращается он - торговец собран заново: новый GameObject, новые рендереры,
ванильные материалы, ТОТ ЖЕ entityId. А в патче стоял HashSet<int> Ghosted -
"этот айди я уже обработал". Айди в наборе есть, свип проходит мимо, торговец
остаётся живым человеком до конца сессии. Отсюда и "на следующее утро": симптом
идёт не за часами лавки, а за чанком, в котором лавка стоит.

Ghosted стал Dictionary<int, GhostBody>, где GhostBody держит массив рендереров,
которые патч взял себе. IsIntact проверяет их: у Unity уничтоженный объект
сравнивается с null, так что подмена модели видна прямо, и та же проверка
бесплатно закрывает любую другую пересборку, не только выгрузку чанка.

Prune чистит Converted и TintedMaterials от уничтоженных объектов. Без него оба
списка росли бы на одного торговца за каждую пересборку, а Retint/Reapply ходили
бы по обломкам. Материал, выданный через renderer.materials, принадлежит
рендереру и умирает вместе с ним - одного прохода хватает на оба списка.

В лог добавлена строка "entity <id> came back with a new model" - ровно та,
которой не хватало, чтобы найти это за один заход вместо разбора в декомпиляторе.

Счётчик в necroghost переименован: со словарём он означает "торговцев под
присмотром сейчас", а не "id, которые когда-либо видели" - он теперь падает и
растёт.

Не проверено в игре: нужен заход к торговцу, выгрузка лавки и возвращение.

---

Traders go back to being ghosts after a chunk unload

Reported from the stream: a trader who had gone black-and-white and translucent
was an ordinary person again the next morning. The guess was the restock - it was
wrong, and that is worth recording, because nothing on the shop's clock touches
the model at all: TraderData's reset rewrites PrimaryInventory and
lastInventoryUpdate only, and TraderArea.SetClosed walks TraderOnOff blocks -
doors, locks, BlockLight, speaker - and never reaches the trader entity.

The real cause is in EntityFactory.CreateEntityOperation.CompleteEntity:
`entity.entityId = ecd.id`, so the saved id is RESTORED. Traders are streamed in
on approach and streamed out the same way when the player leaves for the night.
On return the trader is rebuilt from scratch - new GameObject, new renderers, the
game's own materials - carrying THE SAME entityId. The patch held a
HashSet<int> Ghosted, meaning "this id is done". The id was still in the set, the
sweep skipped him, and he stayed an ordinary person for the rest of the session.
Hence "the next morning": the symptom follows the chunk the shop sits in, not the
shop's clock.

Ghosted is now a Dictionary<int, GhostBody>, the value holding the renderers the
patch took over. IsIntact tests them: Unity's destroyed objects compare equal to
null, so a swapped model is directly visible, and the same check covers any other
rebuild for free.

Prune drops destroyed entries from Converted and TintedMaterials. Without it both
lists would grow by one trader's worth per rebuild and Retint/Reapply would be
walking the wreckage. A material assigned through renderer.materials is owned by
that renderer and dies with it, so one pass settles both.

A log line was added - "entity <id> came back with a new model" - the exact line
that was missing to find this in one visit rather than in a decompiler.

The necroghost counter was reworded: with a dictionary it means "traders held as
ghosts right now", not "ids ever seen" - it now falls as well as rises.

Not tested in game: needs a visit, a shop unload and a return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 22:31:41 +03:00

999 lines
43 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace NecromancerTome
{
/// <summary>
/// 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, 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:
///
/// 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.
///
/// 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.
/// 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.
///
/// The same tick is what puts a trader BACK once the game has rebuilt him - see Ghosted, and
/// the bug of 2026-09-15 that taught this file the difference between an entity id and a
/// model. A spawn hook would not have helped there either: the entity was never re-created as
/// far as its id is concerned.
/// </summary>
public static class GhostTraderPatch
{
/// <summary>Seconds between sweeps.</summary>
public const float SweepInterval = 2f;
/// <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>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>What one trader was actually given, kept so the sweep can ask "is he STILL a
/// ghost" instead of only "have I seen this id". The renderers are the answer: a trader
/// that streams out and back in is rebuilt from scratch - new GameObject, new renderers,
/// the game's own materials - while keeping the id he was saved under, so an id on its own
/// says nothing about the model standing there now. See the Ghosted comment.</summary>
public struct GhostBody
{
public Renderer[] Renderers;
}
/// <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>Traders already converted, by entity id, WITH the renderers each was given.
/// Cleared when the world unloads.
///
/// THE VALUE IS NOT DECORATION - it is the fix for "the trader stopped being a ghost the
/// next morning" (2026-09-15). This was a HashSet of ids, and an id is not enough:
/// EntityFactory restores `entity.entityId = ecd.id` from the save, so a trader who is
/// streamed out while the player is away (they are streamed IN on approach in the first
/// place - see the class comment) comes back as a BRAND NEW GameObject carrying the SAME
/// id, with the game's own materials on it. The set still held the id, the sweep skipped
/// him, and he stayed an ordinary living person for the rest of the session.
///
/// It is NOT the restock, which was the first guess and is worth writing down as ruled
/// out: TraderData's reset rewrites PrimaryInventory and lastInventoryUpdate and touches
/// no renderer, and TraderArea.SetClosed - the whole open/close cycle - only works doors,
/// lights and speakers. Nothing on the shop's clock ever reaches the model. What does is
/// the chunk the shop sits in, which is why the symptom looks like it follows the morning:
/// the player is away for the night, the trader unloads with his chunk, and he is rebuilt
/// when they walk back.
///
/// Holding the renderers makes the question answerable: Unity's destroyed objects compare
/// equal to null, so a trader whose model is gone is visible as such, and the same check
/// covers any other rebuild of the model for free.</summary>
public static readonly Dictionary<int, GhostBody> Ghosted = new Dictionary<int, GhostBody>();
/// <summary>Source shader names already described in the log, so the probe says each
/// distinct thing once rather than once per trader per part.</summary>
public static readonly HashSet<string> ProbedShaders = new HashSet<string>();
/// <summary>Desaturated copies, keyed by the texture they came from.</summary>
public static readonly Dictionary<Texture, Texture2D> GreyTextures = new Dictionary<Texture, Texture2D>();
/// <summary>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".</summary>
public static readonly string[] AlbedoNameHints =
{
"_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap",
"_Diffuse", "_ColorMap", "_MainTexture", "_Texture"
};
public static float timer;
/// <summary>Called from ModEntry.InitMod.</summary>
public static void Init()
{
ModEvents.UnityUpdate.RegisterHandler(OnUnityUpdate);
ModEvents.WorldShuttingDown.RegisterHandler(OnWorldShuttingDown);
}
public static void OnWorldShuttingDown(ref ModEvents.SWorldShuttingDownData _data)
{
Ghosted.Clear();
GreyTextures.Clear();
TintedMaterials.Clear();
Converted.Clear();
BlendShader = null;
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.TryGetValue(trader.entityId, out GhostBody body))
{
if (IsIntact(body))
{
continue;
}
// His model was destroyed and rebuilt under him. Drop what is known about the
// old one before building the new, or Converted and TintedMaterials keep
// entries for renderers and materials that no longer exist.
Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + trader.entityId +
" came back with a new model - ghosting him again");
Ghosted.Remove(trader.entityId);
Prune();
}
if (ApplyGreyscale(trader, out GhostBody fresh))
{
Ghosted[trader.entityId] = fresh;
}
}
}
/// <summary>Is this trader still wearing what we put on him? False the moment any part of
/// the model we converted has been destroyed - which is what a stream-out and back in
/// looks like from here, and equally what any other rebuild of the model would look like.
///
/// Deliberately NOT "does he have renderers we have not converted": a trader gains and
/// loses renderers in normal play (a held item, worn equipment), and treating that as a
/// rebuild would re-run the conversion on renderers already carrying our materials - whose
/// sharedMaterials hand back OUR clones, so the "originals" kept for the next mode switch
/// would be re-shaded ones with no way back. The known gap that leaves is a part of the
/// model built AFTER the first sweep reached him: it stays in colour until he next
/// reloads. Nothing like that has been seen on the six traders.</summary>
public static bool IsIntact(GhostBody _body)
{
if (_body.Renderers == null || _body.Renderers.Length == 0)
{
return false;
}
foreach (Renderer renderer in _body.Renderers)
{
if (renderer == null)
{
return false;
}
}
return true;
}
/// <summary>Drops every entry whose Unity object the game has destroyed. Both lists are
/// session-long and keyed by nothing - without this they grow by one trader's worth of
/// renderers and materials every time a trader is rebuilt, and Retint/Reapply would be
/// walking the wreckage. A material assigned through renderer.materials is owned by that
/// renderer and dies with it, so one pass settles both.</summary>
public static void Prune()
{
for (int i = Converted.Count - 1; i >= 0; i--)
{
if (Converted[i].Renderer == null)
{
Converted.RemoveAt(i);
}
}
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
{
if (TintedMaterials[i].Material == null)
{
TintedMaterials.RemoveAt(i);
}
}
}
/// <summary>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. On true, _body carries
/// the renderers taken over, which is how the next sweep tells this trader from a rebuilt
/// one standing under the same entity id.</summary>
public static bool ApplyGreyscale(EntityTrader _trader, out GhostBody _body)
{
_body = default(GhostBody);
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
if (renderers == null || renderers.Length == 0)
{
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;
List<Renderer> taken = new List<Renderer>(renderers.Length);
foreach (Renderer renderer in renderers)
{
if (renderer == null || renderer is ParticleSystemRenderer)
{
continue;
}
Material[] sources = renderer.sharedMaterials;
if (sources == null || sources.Length == 0)
{
continue;
}
Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources });
taken.Add(renderer);
if (Convert(renderer, sources))
{
converted++;
}
}
_body.Renderers = taken.ToArray();
// 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, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed");
return true;
}
/// <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)
{
return null;
}
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;
}
if (desaturated == null)
{
// Nothing to desaturate on this material; hand back the clone unchanged rather
// than dropping the renderer's material entirely.
return grey;
}
grey.SetTexture(albedoProperty, desaturated);
_changed = true;
return grey;
}
/// <summary>
/// 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:
///
/// 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 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. 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, 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
/// why.
/// </summary>
public static bool ApplyTransparency(Material _material)
{
bool touched = false;
foreach (string hint in TintNameHints)
{
if (!_material.HasProperty(hint))
{
continue;
}
Color tint = _material.GetColor(hint);
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;
}
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;
}
/// <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>
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;
}
/// <summary>Black-and-white copy of a texture. See the class comment for why this goes
/// through a RenderTexture instead of reading the source directly.</summary>
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;
}
/// <summary>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.</summary>
public static void ProbeShaderOnce(Material _source)
{
Shader shader = _source.shader;
string shaderName = shader != null ? shader.name : "<null shader>";
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 = "<none>";
foreach (string hint in TintNameHints)
{
if (_source.HasProperty(hint))
{
tint = hint + " (alpha " + _source.GetColor(hint).a.ToString("0.###") + ")";
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 + "; fade property: " + fade +
"; blend-mode properties present: " + canBlend);
}
}
}