Хранилище браслета переживает перезапуск; торговцы чёрно-белые
Исправляет первый баг-репорт мода на 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,15 @@ namespace NecromancerTome
|
|||||||
var harmony = new Harmony("necromancertome.harmony");
|
var harmony = new Harmony("necromancertome.harmony");
|
||||||
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
harmony.PatchAll(Assembly.GetExecutingAssembly());
|
||||||
PetFollowPatch.Init();
|
PetFollowPatch.Init();
|
||||||
|
// SpatialVaultPersistence needs NO Init(): it is four Harmony postfixes that PatchAll
|
||||||
|
// above already attached. It used to register a WorldShuttingDown handler to clear its
|
||||||
|
// cache - that handler is exactly what wiped the vault on every clean exit, because
|
||||||
|
// that event fires BEFORE the final player save (GameManager.SaveAndCleanupWorld:
|
||||||
|
// event at IL_0026, SaveLocalPlayerData at IL_00c4). Freshness is decided by what was
|
||||||
|
// read instead; see that file.
|
||||||
|
// Traders rendered as washed-out ghosts (request 2026-09-13). Polls rather than
|
||||||
|
// hooks a spawn event - see that file for why the SDCS-built trader forces it.
|
||||||
|
GhostTraderPatch.Init();
|
||||||
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
// PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
|
||||||
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
|
||||||
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
|
||||||
|
|||||||
@@ -30,19 +30,15 @@ namespace NecromancerTome
|
|||||||
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the
|
||||||
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
/// Knife's own "0 at 0 kills is a feature, not a bug" precedent - a tooltip explains it
|
||||||
/// instead of silently opening a useless empty window.
|
/// instead of silently opening a useless empty window.
|
||||||
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
|
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
|
||||||
/// assumed: the Bag backing each player's vault lives in a plain in-memory
|
/// when this item shipped, and that shortfall is exactly what became the mod's first
|
||||||
/// Dictionary<int, Bag> in this file (PlayerVaults below), keyed by entityId. This
|
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
|
||||||
/// is reliable for as long as the game process keeps running (survives death/respawn/
|
/// disappear after leaving the game and going online again"). PlayerVaults below is still
|
||||||
/// relogging within one play session, confirmed by how a static field behaves) but has
|
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
|
||||||
/// NOT been wired into any save/load system - closing the game entirely and reloading the
|
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
|
||||||
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
|
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
|
||||||
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
|
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
|
||||||
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
|
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
|
||||||
/// world entity the way the summoned pets do - unconfirmed whether THOSE actually survive
|
|
||||||
/// a full restart either) is real, separate follow-up work, not attempted here. Treat
|
|
||||||
/// this like a session-scoped stash until that's built and confirmed - don't rely on it
|
|
||||||
/// across game restarts yet.
|
|
||||||
///
|
///
|
||||||
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
|
||||||
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
|
||||||
@@ -71,8 +67,9 @@ namespace NecromancerTome
|
|||||||
public const float MaxRange = 50f;
|
public const float MaxRange = 50f;
|
||||||
public const float ShoveDistance = 6f;
|
public const float ShoveDistance = 6f;
|
||||||
|
|
||||||
/// <summary>See the class-level comment above for exactly what this does and doesn't
|
/// <summary>Session cache only - the durable copy lives on disk, see
|
||||||
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
|
/// SpatialVaultPersistence.cs. Cleared on WorldShuttingDown so a different save loaded
|
||||||
|
/// afterwards cannot inherit this world's vault through a recycled entityId.</summary>
|
||||||
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
|
||||||
|
|
||||||
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
public static bool Prefix(ItemActionData _actionData, bool _bReleased)
|
||||||
@@ -118,7 +115,17 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag))
|
||||||
{
|
{
|
||||||
bag = new Bag(slotCount);
|
// Normally a restored vault is already here - the ToPlayer postfix puts it in
|
||||||
|
// when the game applies the save file to the spawning player. LastLoadedVault is
|
||||||
|
// the safety net for when that chain does not complete: opening the bracelet must
|
||||||
|
// never be what silently starts an empty vault over a saved one. Only then is a
|
||||||
|
// genuinely new bag created.
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
|
||||||
|
if (bag == SpatialVaultPersistence.LastLoadedVault)
|
||||||
|
{
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: session cache was empty, adopted the last loaded vault (" +
|
||||||
|
bag.SlotCount + " slots, " + bag.GetUsedSlotCount() + " used)");
|
||||||
|
}
|
||||||
PlayerVaults[player.entityId] = bag;
|
PlayerVaults[player.entityId] = bag;
|
||||||
}
|
}
|
||||||
else if (bag.SlotCount < slotCount)
|
else if (bag.SlotCount < slotCount)
|
||||||
@@ -134,7 +141,21 @@ namespace NecromancerTome
|
|||||||
|
|
||||||
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
|
||||||
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
|
||||||
XUiC_BagStorageWindowGroup.Open(playerUI.xui, player, bag, LootContainer.GetLootContainer("roboticDrone"), Localization.Get("braceletSpatialVaultWindowTitle"));
|
// The trailing callbacks are vanilla's own optional parameters (_onModified, _onClose).
|
||||||
|
// _onModified is not needed: the vault lives in PlayerVaults, and PlayerDataFile's
|
||||||
|
// FromPlayer postfix reads it fresh every time the game saves the player, so there is
|
||||||
|
// nothing to flush per item move. _onClose asks for a player-data save right away, so
|
||||||
|
// closing the window is a commit point rather than waiting for the next autosave -
|
||||||
|
// SaveLocalPlayerData is the game's own routine call and no-ops when saving is not
|
||||||
|
// active (which is the correct behaviour on a client, where the server owns the file).
|
||||||
|
XUiC_BagStorageWindowGroup.Open(
|
||||||
|
playerUI.xui,
|
||||||
|
player,
|
||||||
|
bag,
|
||||||
|
LootContainer.GetLootContainer("roboticDrone"),
|
||||||
|
Localization.Get("braceletSpatialVaultWindowTitle"),
|
||||||
|
null,
|
||||||
|
() => GameManager.Instance.SaveLocalPlayerData());
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
public static void ShoveZombieAtCrosshair(EntityPlayerLocal player)
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using HarmonyLib;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Cross-restart persistence for the Spatial Bracelet's vault - the fix for the first bug
|
||||||
|
/// report the mod ever got on Nexus (youkia96581, 11 Sep 2026: "Items stored in the space
|
||||||
|
/// bracelet will disappear after leaving the game and going online again").
|
||||||
|
///
|
||||||
|
/// WHY IT LIVES IN THE PLAYER'S SAVE FILE - "почему не сделать принцип как у ящика?" (user,
|
||||||
|
/// 13.09.2026). Right question, and it decided the design. A chest keeps its items because
|
||||||
|
/// they live in a TileEntity, and a TileEntity belongs to a CHUNK: decompiled, `TileEntity`
|
||||||
|
/// has chunkPos and chunk fields and its ONLY constructor is TileEntity(Chunk). The game saves
|
||||||
|
/// and syncs the chunk; the container rides along. That is the whole trick - not a "storage
|
||||||
|
/// system" one can call, but a home in something the engine already persists. The bracelet has
|
||||||
|
/// no position and no chunk, so it got the closest equivalent for something personal: the
|
||||||
|
/// player's own save data, written right after everything vanilla writes, in the same file and
|
||||||
|
/// the same moment as the backpack.
|
||||||
|
///
|
||||||
|
/// THAT ALSO ANSWERS THE ID QUESTION ("у браслета, как и у ящика, наверняка есть id"). A
|
||||||
|
/// chest's id IS its position. An item has no per-instance id by default - ItemValue.type is
|
||||||
|
/// the item CLASS, identical on every bracelet - but ItemValue.Metadata would hold one and
|
||||||
|
/// genuinely round-trips through saves (ItemValue.Write writes it, ItemValue.ReadData reads it
|
||||||
|
/// back; both checked). Per-bracelet vaults are therefore buildable and deliberately not built:
|
||||||
|
/// keying by the item means losing the bracelet locks the items away forever even though they
|
||||||
|
/// are still in the save file, and it would let ten bracelets be ten warehouses. Keying by the
|
||||||
|
/// player - which storing them IN the player's file does for free - has neither problem.
|
||||||
|
///
|
||||||
|
/// THE FOUR HOOKS:
|
||||||
|
/// FromPlayer - live player -> file object: attach that player's vault to the file.
|
||||||
|
/// Write - file object -> bytes (Save to disk, or WriteNetwork to the wire, which is
|
||||||
|
/// literally Write + PlayerMetaInfo): append the vault blob.
|
||||||
|
/// Read - bytes -> file object: pull the vault back off the stream.
|
||||||
|
/// ToPlayer - file object -> live player: hand the vault back.
|
||||||
|
/// FromPlayer always reads the CURRENT vault, so there is no dirty flag and no save scheduling
|
||||||
|
/// to get wrong: whenever the game saves the player, it saves the vault.
|
||||||
|
///
|
||||||
|
/// ================================================================================
|
||||||
|
/// THE BUG THAT COST TWO TEST RUNS, AND WHY IT IS WORTH A BIG COMMENT
|
||||||
|
/// ================================================================================
|
||||||
|
/// Earlier versions cleared the session cache from a ModEvents.WorldShuttingDown handler, to
|
||||||
|
/// stop one save's vault leaking into the next. The user reported the vault kept losing its
|
||||||
|
/// contents, and the diagnostics printed the murder weapon in order:
|
||||||
|
///
|
||||||
|
/// INF SaveAndCleanupWorld
|
||||||
|
/// [NecromancerTome] world shutting down, dropped 1 in-memory vault(s)
|
||||||
|
/// [NecromancerTome] FromPlayer entity 171 - vault NONE
|
||||||
|
/// [NecromancerTome] Write - no vault attached (writes an EMPTY marker)
|
||||||
|
///
|
||||||
|
/// **WorldShuttingDown fires BEFORE the final player save, not after.** Confirmed in
|
||||||
|
/// GameManager.SaveAndCleanupWorld by decompilation rather than inferred from the log: the
|
||||||
|
/// event is invoked at IL_0026 and SaveLocalPlayerData() is called at IL_00c4, a hundred-odd
|
||||||
|
/// instructions later. So the handler emptied the cache, and the save that followed
|
||||||
|
/// faithfully recorded "this player has no vault" over the real one. Every clean exit wiped
|
||||||
|
/// the vault - which is exactly the symptom the Nexus report described, reintroduced by the
|
||||||
|
/// fix for it.
|
||||||
|
///
|
||||||
|
/// There is no documentation to have checked first: the community consensus is that the
|
||||||
|
/// official ModAPI is barebones and has no reference for event ordering, so the decompiler is
|
||||||
|
/// the only authority. Treat every ModEvent's position in the shutdown sequence as unknown
|
||||||
|
/// until read out of the method that invokes it.
|
||||||
|
///
|
||||||
|
/// TWO RULES CAME OUT OF IT, and both are load-bearing here:
|
||||||
|
///
|
||||||
|
/// 1. A RESTORE PATH MAY FAIL; IT MAY NEVER DELETE. An empty session cache is not evidence
|
||||||
|
/// that the player has no vault - it is the absence of evidence. LastLoadedVault below is
|
||||||
|
/// the safety net, so a broken restore chain costs a restore, not the data.
|
||||||
|
/// 2. FRESHNESS IS DECIDED BY WHAT WAS READ, NOT BY A TIMER. Cross-save leaking is now
|
||||||
|
/// prevented by ToPlayer being authoritative: a player file that was read and explicitly
|
||||||
|
/// carried no vault CLEARS the cache. Nothing has to be cleared "at the right moment"
|
||||||
|
/// any more, which is what made the old approach fragile in the first place.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultPersistence
|
||||||
|
{
|
||||||
|
/// <summary>Payload layout version, independent of the blob framing in
|
||||||
|
/// SpatialVaultBlobIO. An unknown version is skipped, not guessed at - the framing's
|
||||||
|
/// explicit length means we can always step over a payload we do not understand.</summary>
|
||||||
|
public const byte PayloadVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>What a PlayerDataFile carries. A class rather than a bare Bag because its mere
|
||||||
|
/// PRESENCE is information: "this file has been read/filled, and the answer - including a
|
||||||
|
/// null Bag - is authoritative". ConditionalWeakTable cannot store null, so a null Bag
|
||||||
|
/// needs a wrapper to be expressible at all.</summary>
|
||||||
|
public class VaultSlot
|
||||||
|
{
|
||||||
|
public Bag Bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Vault attached to a PlayerDataFile while it is being written, read or
|
||||||
|
/// converted. Weak, because PlayerDataFile objects are created fresh for every save and
|
||||||
|
/// every network packet and nothing here should keep one alive.</summary>
|
||||||
|
public static readonly ConditionalWeakTable<PlayerDataFile, VaultSlot> AttachedVaults =
|
||||||
|
new ConditionalWeakTable<PlayerDataFile, VaultSlot>();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Last vault seen this session, kept outside the weak table. This is rule 1 above made
|
||||||
|
/// concrete: if the Read -> ToPlayer -> PlayerVaults chain ever fails to complete, the bag
|
||||||
|
/// is still here, so the next save writes the real contents instead of an empty marker.
|
||||||
|
///
|
||||||
|
/// SINGLE LOCAL PLAYER ONLY. There is one of these per process, so on a dedicated server
|
||||||
|
/// it would be one player's vault handed to whoever asked next. Every use is gated on the
|
||||||
|
/// player being an EntityPlayerLocal - which a dedicated server does not have, and a host
|
||||||
|
/// or single-player game has exactly one of.
|
||||||
|
/// </summary>
|
||||||
|
public static Bag LastLoadedVault;
|
||||||
|
|
||||||
|
/// <summary>Last line printed by the save path, so an unchanged vault saved over and over
|
||||||
|
/// does not repeat itself in the log. Kept 2026-09-13 when the fix was confirmed: the
|
||||||
|
/// save pair fires on every autosave, and a player's log should not carry two lines of
|
||||||
|
/// inventory listing every few minutes - but the moment anything CHANGES it still says so,
|
||||||
|
/// which is the part that had diagnostic value.</summary>
|
||||||
|
public static string lastSaveLogged;
|
||||||
|
|
||||||
|
/// <summary>Builds the opaque payload SpatialVaultBlobIO wraps. Uses netstandard's own
|
||||||
|
/// BinaryWriter over a MemoryStream, which is why Bag serialization can stay in this
|
||||||
|
/// project instead of the satellite assembly.</summary>
|
||||||
|
public static byte[] BuildPayload(Bag _bag)
|
||||||
|
{
|
||||||
|
using (MemoryStream ms = new MemoryStream())
|
||||||
|
using (BinaryWriter bw = new BinaryWriter(ms))
|
||||||
|
{
|
||||||
|
bw.Write(PayloadVersion);
|
||||||
|
bool hasBag = _bag != null;
|
||||||
|
bw.Write(hasBag);
|
||||||
|
if (hasBag)
|
||||||
|
{
|
||||||
|
// Plain BinaryWriter is enough: Bag.Write only demands a PooledBinaryWriter
|
||||||
|
// when bag.preferences != null, and vault bags come from `new Bag(int)`, whose
|
||||||
|
// constructor sets nothing but the item array.
|
||||||
|
_bag.Write(bw);
|
||||||
|
}
|
||||||
|
bw.Flush();
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Null when the payload holds no vault or is a version we do not know.</summary>
|
||||||
|
public static Bag ParsePayload(byte[] _payload)
|
||||||
|
{
|
||||||
|
if (_payload == null || _payload.Length == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
using (MemoryStream ms = new MemoryStream(_payload, false))
|
||||||
|
using (BinaryReader br = new BinaryReader(ms))
|
||||||
|
{
|
||||||
|
byte version = br.ReadByte();
|
||||||
|
if (version != PayloadVersion)
|
||||||
|
{
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: vault payload version " + version + ", expected " + PayloadVersion + " - skipped");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!br.ReadBoolean())
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// Bag.Read is the STATIC one and returns a new Bag; ReadInto is the instance
|
||||||
|
// version. Symmetric with BuildPayload: preferences were written as absent, so no
|
||||||
|
// PooledBinaryReader is needed here either.
|
||||||
|
return Bag.Read(br);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Attach(PlayerDataFile _file, Bag _bag)
|
||||||
|
{
|
||||||
|
AttachedVaults.Remove(_file);
|
||||||
|
AttachedVaults.Add(_file, new VaultSlot { Bag = _bag });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Contents of a bag, for the log. Item names rather than just a count, because
|
||||||
|
/// "2 slots, 0 used" was true and useless three test runs in a row - what was needed was
|
||||||
|
/// whether the items the user put in had actually reached this object.</summary>
|
||||||
|
public static string Describe(Bag _bag)
|
||||||
|
{
|
||||||
|
if (_bag == null)
|
||||||
|
{
|
||||||
|
return "NONE";
|
||||||
|
}
|
||||||
|
ItemStack[] slots = _bag.GetSlots();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.Append(_bag.SlotCount).Append(" slots, ").Append(_bag.GetUsedSlotCount()).Append(" used");
|
||||||
|
if (slots != null)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < slots.Length; i++)
|
||||||
|
{
|
||||||
|
ItemStack stack = slots[i];
|
||||||
|
if (stack == null || stack.IsEmpty())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
string name = stack.itemValue != null && stack.itemValue.ItemClass != null
|
||||||
|
? stack.itemValue.ItemClass.GetItemName()
|
||||||
|
: "?";
|
||||||
|
sb.Append(" [").Append(i).Append("]=").Append(name).Append("x").Append(stack.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Live player -> save file: take the vault along.</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "FromPlayer")]
|
||||||
|
public static class Patch_PlayerDataFile_FromPlayer_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag);
|
||||||
|
string source = bag != null ? "session cache" : null;
|
||||||
|
if (bag == null && _player is EntityPlayerLocal && SpatialVaultPersistence.LastLoadedVault != null)
|
||||||
|
{
|
||||||
|
// Rule 1: never write "no vault" over a vault we know exists.
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault;
|
||||||
|
source = "last loaded (session cache was empty)";
|
||||||
|
}
|
||||||
|
SpatialVaultPersistence.Attach(__instance, bag);
|
||||||
|
string line = "FromPlayer entity " + _player.entityId + " - " + SpatialVaultPersistence.Describe(bag) +
|
||||||
|
(source != null ? ", from " + source : "");
|
||||||
|
if (line != SpatialVaultPersistence.lastSaveLogged)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.lastSaveLogged = line;
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: " + line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: FromPlayer postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Save file -> live player: hand the vault back. This is also where freshness is decided
|
||||||
|
/// (rule 2): a file that WAS read and explicitly carried no vault clears the cache, so loading
|
||||||
|
/// a different save cannot inherit the previous world's vault. Only a file that was never read
|
||||||
|
/// at all falls back to LastLoadedVault, which is the broken-chain safety net.
|
||||||
|
/// </summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "ToPlayer")]
|
||||||
|
public static class Patch_PlayerDataFile_ToPlayer_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, EntityPlayer _player)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_player == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bool isLocal = _player is EntityPlayerLocal;
|
||||||
|
string note;
|
||||||
|
Bag bag;
|
||||||
|
|
||||||
|
if (SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot))
|
||||||
|
{
|
||||||
|
bag = slot.Bag;
|
||||||
|
note = bag != null ? "from this player file" : "this player file says there is no vault";
|
||||||
|
}
|
||||||
|
else if (isLocal && SpatialVaultPersistence.LastLoadedVault != null)
|
||||||
|
{
|
||||||
|
bag = SpatialVaultPersistence.LastLoadedVault;
|
||||||
|
note = "nothing attached to this file - fell back to the last loaded vault";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
bag = null;
|
||||||
|
note = "nothing attached and nothing loaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bag != null)
|
||||||
|
{
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.Remove(_player.entityId);
|
||||||
|
}
|
||||||
|
if (isLocal)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.LastLoadedVault = bag;
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: ToPlayer entity " + _player.entityId +
|
||||||
|
" - " + SpatialVaultPersistence.Describe(bag) + " (" + note + ")");
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: ToPlayer postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Appends the vault after everything vanilla wrote - to disk via Save, or to the
|
||||||
|
/// wire via WriteNetwork.</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "Write")]
|
||||||
|
public static class Patch_PlayerDataFile_Write_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, PooledBinaryWriter _bw)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.AttachedVaults.TryGetValue(__instance, out SpatialVaultPersistence.VaultSlot slot);
|
||||||
|
Bag bag = slot != null ? slot.Bag : null;
|
||||||
|
SpatialVaultBlobIO.Write(_bw, SpatialVaultPersistence.BuildPayload(bag));
|
||||||
|
if (bag == null)
|
||||||
|
{
|
||||||
|
// Always shouted: writing an empty marker is how the vault got destroyed twice,
|
||||||
|
// so it must never again scroll past unnoticed.
|
||||||
|
Debug.LogWarning("[NecromancerTome] SpatialVaultPersistence: Write - no vault attached (writes an EMPTY marker)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Write postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the vault back off the stream. Must never throw: PlayerDataFile.Load treats
|
||||||
|
/// any exception out of Read as "this save is broken, fall back to the .bak".</summary>
|
||||||
|
[HarmonyPatch(typeof(PlayerDataFile), "Read")]
|
||||||
|
public static class Patch_PlayerDataFile_Read_SpatialVault
|
||||||
|
{
|
||||||
|
public static void Postfix(PlayerDataFile __instance, PooledBinaryReader _br)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
byte[] payload = SpatialVaultBlobIO.TryRead(_br);
|
||||||
|
if (payload == null)
|
||||||
|
{
|
||||||
|
// No vault block: a save from before this feature existed, or player data from
|
||||||
|
// somebody without the mod. Deliberately NOT recorded as an authoritative
|
||||||
|
// "no vault" - an absent block is silence, not a denial, and ToPlayer's
|
||||||
|
// fallback is what should handle it. SpatialVaultBlobIO has already put the
|
||||||
|
// stream position back.
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - no vault block on this stream");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Bag bag = SpatialVaultPersistence.ParsePayload(payload);
|
||||||
|
// Attached even when null: a blob that says "no vault" IS an answer, and ToPlayer
|
||||||
|
// uses it to clear a stale cache when a different save is loaded.
|
||||||
|
SpatialVaultPersistence.Attach(__instance, bag);
|
||||||
|
if (bag != null)
|
||||||
|
{
|
||||||
|
SpatialVaultPersistence.LastLoadedVault = bag;
|
||||||
|
}
|
||||||
|
Debug.Log("[NecromancerTome] SpatialVaultPersistence: Read - blob of " + payload.Length +
|
||||||
|
" byte(s), " + SpatialVaultPersistence.Describe(bag));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Debug.LogError("[NecromancerTome] SpatialVaultPersistence: Read postfix failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,99 @@
|
|||||||
|
using System.IO;
|
||||||
|
|
||||||
|
namespace NecromancerTome
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Raw byte-level half of the Spatial Bracelet's vault persistence. Lives in this satellite
|
||||||
|
/// assembly for exactly the reason PyramidWardWriteHelper.cs documents: PooledBinaryWriter's
|
||||||
|
/// Write overload set cannot be resolved from the main project at all (CS7069), so anything
|
||||||
|
/// that actually touches a PooledBinaryWriter/PooledBinaryReader has to be compiled here,
|
||||||
|
/// against the game's own mscorlib.
|
||||||
|
///
|
||||||
|
/// The split is deliberately drawn so that ONLY primitives cross it: this file knows about
|
||||||
|
/// byte arrays and stream positions, nothing else. Bag/ItemStack serialization stays in the
|
||||||
|
/// main project, where `Bag.Write(BinaryWriter)` against netstandard's own BinaryWriter
|
||||||
|
/// already compiles fine (proven - that is how the vault blob is built). Keeping Bag out of
|
||||||
|
/// here also keeps UnityEngine out of here, which this project's reference setup (NoStdLib +
|
||||||
|
/// the game's mscorlib, no UnityEngine at all) cannot tolerate.
|
||||||
|
///
|
||||||
|
/// BLOB LAYOUT, appended after everything vanilla PlayerDataFile.Write produces:
|
||||||
|
///
|
||||||
|
/// int64 Magic "NECROVLT"
|
||||||
|
/// int32 payloadLength
|
||||||
|
/// byte[] payload (opaque here; the main project builds and parses it)
|
||||||
|
///
|
||||||
|
/// The magic plus the explicit length is what makes this safe to append to somebody else's
|
||||||
|
/// format. On read we remember the stream position first: if the magic is not there (an old
|
||||||
|
/// save written before this feature, or a player-data packet from a party that does not have
|
||||||
|
/// the mod) the position is put back exactly where it was and the caller is told "no vault" -
|
||||||
|
/// so whatever the game reads next still reads the right bytes. That matters concretely:
|
||||||
|
/// PlayerDataFile.ReadNetwork calls Read and then goes on to read PlayerMetaInfo from the
|
||||||
|
/// same stream, and PlayerDataFile.Load treats ANY exception out of Read as "file is broken,
|
||||||
|
/// roll back to the .bak". Neither may be disturbed, so nothing here throws.
|
||||||
|
/// </summary>
|
||||||
|
public static class SpatialVaultBlobIO
|
||||||
|
{
|
||||||
|
/// <summary>ASCII "NECROVLT" as one int64 - distinctive enough that stray bytes will not
|
||||||
|
/// be mistaken for our block.</summary>
|
||||||
|
public const long Magic = 0x4E4543524F564C54L;
|
||||||
|
|
||||||
|
/// <summary>Magic (8) + length (4).</summary>
|
||||||
|
public const int HeaderSize = 12;
|
||||||
|
|
||||||
|
public static void Write(PooledBinaryWriter _bw, byte[] _payload)
|
||||||
|
{
|
||||||
|
if (_bw == null || _payload == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_bw.Write(Magic);
|
||||||
|
_bw.Write(_payload.Length);
|
||||||
|
_bw.Write(_payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the payload, or null when this stream carries no vault block. Never
|
||||||
|
/// throws, and never leaves the stream anywhere the caller did not expect: either just
|
||||||
|
/// past our whole block, or exactly back where it started.</summary>
|
||||||
|
public static byte[] TryRead(PooledBinaryReader _br)
|
||||||
|
{
|
||||||
|
if (_br == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream stream = _br.BaseStream;
|
||||||
|
if (stream == null || !stream.CanSeek)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
long startPosition = stream.Position;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (stream.Length - startPosition < HeaderSize)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (_br.ReadInt64() != Magic)
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int length = _br.ReadInt32();
|
||||||
|
if (length < 0 || stream.Length - stream.Position < length)
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _br.ReadBytes(length);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
stream.Position = startPosition;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user