Хранилище браслета переживает перезапуск; торговцы чёрно-белые
Исправляет первый баг-репорт мода на 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:
co-authored by
Claude Opus 5
parent
a5f8592903
commit
29431990f6
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user