Хранилище браслета переживает перезапуск; торговцы чёрно-белые

Исправляет первый баг-репорт мода на Nexus (youkia96581, 11.09.2026):
"Items stored in the space bracelet will disappear after leaving the game
and going online again". Причина была записана в коде как нерешённая:
PlayerVaults - обычный статический Dictionary, save/load не существовало.

ХРАНИЛИЩЕ ТЕПЕРЬ ЖИВЁТ В PlayerDataFile, рядом с рюкзаком игрока. Так
решено после вопроса пользователя "почему не сделать принцип как у ящика?":
ящик хранит вещи тем, что они лежат в чанке (у TileEntity единственный
конструктор TileEntity(Chunk)), а браслету нужен был дом в чём-то, что
движок и так сохраняет. Четыре постфикса - FromPlayer/Write/Read/ToPlayer,
блоб с магией "NECROVLT" и явной длиной дописывается после всего
ванильного. Байтовая часть - в сателлитной сборке: PooledBinaryWriter.Write
не резолвится из основного проекта (CS7069), как и у PyramidWardWriteHelper.

Два дефекта, найденные и убитые по дороге живыми тестами:

1. ModEvents.WorldShuttingDown приходит ПЕРЕД финальным сохранением игрока
   (GameManager.SaveAndCleanupWorld: событие на IL_0026, SaveLocalPlayerData
   на IL_00c4). Обработчик, чистивший там кэш, затирал хранилище на каждом
   корректном выходе. Обработчик убран; свежесть решает авторитетность
   ToPlayer, а не таймер.
2. Пустой сессионный кэш трактовался как "хранилища нет" и записывался
   поверх настоящего. Путь восстановления имеет право не сработать, удалять
   он права не имеет - добавлена страховка LastLoadedVault.

Проверено в игре: положил -> вышел -> запустил заново -> вещи на месте,
блоб на 54 байта сверен в .ttp побайтово.

ТОРГОВЦЫ (npcTraderJoel/Rekt/Bob/Hugh/Jen) - чёрно-белые. Шейдер НЕ
подменяется: материал клонируется со своим шейдером, меняется только
текстура альбедо на обесцвеченную копию, так что свет, нормали и скиннинг
остаются движковыми. Альбедо ищется обходом свойств шейдера, а не по имени:
тело - Game/Character/_Albedo, волосы - Game/Autodesk/_MainTex. Плюс 1%
прозрачности с сохранением _ZWrite. Опрос раз в 2 с, потому что торговцы
стримятся на подходе, а Джен собирается в рантайме.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
This commit is contained in:
AlexCube
2026-09-13 19:54:48 +03:00
co-authored by Claude Opus 5
parent a5f8592903
commit 29431990f6
9 changed files with 931 additions and 17 deletions
+419
View File
@@ -0,0 +1,419 @@
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. The user first said "убираем
/// совсем", confirmed the result ("торговец стал непрозрачным и полностью чёрно-белым, как и
/// требовалось"), and then asked for "лёгкую прозрачность, буквально 1%" to push him a little
/// further towards a ghost. Dropping it was still the release this effect needed, because it
/// is what allowed the shader to stay put - see below; the 1% is now a separate, optional
/// layer on top (ApplyTransparency) that cannot break the greyscale if the shaders refuse it.
///
/// The two failed attempts are worth keeping written down, because neither could have been
/// predicted from the decompiler and each was settled by one log line:
///
/// 1. "Unlit/Transparent Greyscale" (what GameManager uses for greyed-out item icons) has
/// NO _Color - nowhere to put an alpha. Traders came out opaque, and because that shader
/// is built for NGUI atlases rather than skinned meshes, the user saw them "в негативе".
/// 2. "Unlit/Transparent Colored" has no _Color either: `has _Color: False` in the log.
/// NGUI tints through VERTEX colours, not a material property, and a character mesh has
/// none - so that whole family of shaders was always a dead end here.
///
/// WITHOUT THE ALPHA REQUIREMENT THE SHADER DOES NOT HAVE TO BE REPLACED AT ALL, and that is
/// strictly better than anything above: the material is cloned with its own shader intact and
/// only its albedo texture is swapped for a desaturated copy. Lighting, normal maps, specular,
/// skinning - all still the game's own. The trader looks exactly like himself, in black and
/// white. Nothing can go "negative", because nothing but the pixels changes.
///
/// THE ALBEDO IS FOUND, NOT ASSUMED - this is what the earlier runs bought us. The first
/// attempt reached for _MainTex and produced an untextured silhouette, because traders are
/// drawn by TWO different shaders and only one of them uses that name. The probe below walks
/// the shader's declared properties instead, and the log then said exactly what they are:
///
/// shader 'Game/Character' texture properties: _Albedo=set, _Normal=set, _RMOE=set,
/// _texcoord=empty; chosen albedo: HD_Rekt 4096x4096
/// shader 'Game/Autodesk' texture properties: _MainTex=set, _BumpMap=set, ...;
/// chosen albedo: HD_Rekt_Hair 2048x2048
///
/// So the body uses _Albedo and the hair uses _MainTex - which is precisely why the name is
/// discovered rather than hard-coded, and why the probe stays in: Jen is assembled by a
/// different character system than Rekt (AvatarSDCSController vs AvatarNpcController in
/// entityclasses.xml) and may well introduce a third shader.
///
/// GREYSCALE IS DONE TO THE TEXTURE, via a RenderTexture round trip. The round trip is the
/// point: game textures are compressed with isReadable=false, so GetPixels on the original
/// throws - blitting into an ARGB32 RenderTexture and reading THAT back is the standard way to
/// reach pixels the CPU was never handed. Luma weights 0.299/0.587/0.114 rather than a flat
/// average, so it reads like a black-and-white photograph instead of a muddy one. Cached per
/// source texture: these are 4096x4096, and a readback per renderer per sweep would be
/// indefensible.
///
/// WHY A TICK AND NOT A SPAWN HOOK. Traders are streamed in on approach ("force spawning
/// pending entity npcTraderRekt" appeared ~4 minutes after the world loaded), and Jen is built
/// at runtime, so her renderers do not all exist when the entity is added to the world.
/// Polling with ModEvents.UnityUpdate - the same approach PetFollowPatch.cs already uses here -
/// avoids guessing at the right moment inside someone else's character pipeline. A trader with
/// no renderers yet is simply not marked done and is picked up on the next sweep.
/// </summary>
public static class GhostTraderPatch
{
/// <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>Colour properties that might carry an alpha, best first.</summary>
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary>
public static readonly HashSet<int> Ghosted = new HashSet<int>();
/// <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();
timer = 0f;
}
public static void OnUnityUpdate(ref ModEvents.SUnityUpdateData _data)
{
timer += Time.deltaTime;
if (timer < SweepInterval)
{
return;
}
timer = 0f;
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null || world.EntityAlives == null)
{
return;
}
for (int i = 0; i < world.EntityAlives.Count; i++)
{
EntityAlive entity = world.EntityAlives[i];
if (!(entity is EntityTrader trader) || trader.IsDead())
{
continue;
}
if (Ghosted.Contains(trader.entityId))
{
continue;
}
if (ApplyGreyscale(trader))
{
Ghosted.Add(trader.entityId);
}
}
}
/// <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.</summary>
public static bool ApplyGreyscale(EntityTrader _trader)
{
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
if (renderers == null || renderers.Length == 0)
{
return false;
}
int converted = 0;
foreach (Renderer renderer in renderers)
{
if (renderer == null || renderer is ParticleSystemRenderer)
{
continue;
}
Material[] sources = renderer.sharedMaterials;
if (sources == null || sources.Length == 0)
{
continue;
}
Material[] greys = new Material[sources.Length];
bool anyChanged = false;
for (int i = 0; i < sources.Length; i++)
{
greys[i] = MakeGreyMaterial(sources[i], ref anyChanged);
}
if (anyChanged)
{
renderer.materials = greys;
converted++;
}
}
Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName +
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length + " renderer(s) desaturated");
return true;
}
/// <summary>Clone of the source material - SAME shader, same everything - with only its
/// albedo replaced by a black-and-white copy.</summary>
public static Material MakeGreyMaterial(Material _source, ref bool _changed)
{
if (_source == null)
{
return null;
}
ProbeShaderOnce(_source);
Material grey = new Material(_source);
if (ApplyTransparency(grey))
{
_changed = true;
}
string albedoProperty = FindAlbedoProperty(_source);
if (albedoProperty == null)
{
// Nothing to desaturate on this material; hand back the clone unchanged rather
// than dropping the renderer's material entirely.
return grey;
}
Texture2D desaturated = Desaturate(_source.GetTexture(albedoProperty));
if (desaturated == null)
{
return grey;
}
grey.SetTexture(albedoProperty, desaturated);
_changed = true;
return grey;
}
/// <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:
///
/// - A COLOUR with an alpha channel (_Color and friends). This is the only thing that
/// actually sets the opacity.
/// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is
/// handed, so without this the first lever does nothing visible - the same wall the
/// mod's first transparency attempt hit back on 2026-08-28 with the summoned pets.
/// The recipe is the game's own: MeshDescription.SetupMaterialWithBlendMode writes
/// exactly these properties plus _ZWrite and the _ALPHABLEND_ON keyword.
///
/// _ZWrite IS LEFT ALONE ON PURPOSE. The usual recipe switches depth writing off, which is
/// right for glass and wrong for a person: without it every part of the model shows through
/// every other part and the trader turns into a soup of overlapping limbs. At 99% opacity
/// there is nothing to see through anyway, so keeping depth writing costs nothing visible
/// and avoids that entirely.
///
/// Whatever is missing is reported by the probe rather than silently skipped - if neither
/// lever exists on these shaders, the traders stay solid black-and-white and the log says
/// why.
/// </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);
tint.a *= GhostAlpha;
_material.SetColor(hint, tint);
touched = true;
break;
}
if (_material.HasProperty("_SrcBlend") && _material.HasProperty("_DstBlend"))
{
_material.SetFloat("_SrcBlend", (float)BlendMode.SrcAlpha);
_material.SetFloat("_DstBlend", (float)BlendMode.OneMinusSrcAlpha);
_material.EnableKeyword("_ALPHABLEND_ON");
_material.renderQueue = (int)RenderQueue.Transparent;
touched = true;
}
return touched;
}
/// <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;
}
}
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);
}
}
}