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

Исправляет первый баг-репорт мода на 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);
}
}
}
+9
View File
@@ -21,6 +21,15 @@ namespace NecromancerTome
var harmony = new Harmony("necromancertome.harmony");
harmony.PatchAll(Assembly.GetExecutingAssembly());
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
// automatically by the engine's own TileEntityCompositeData reflection scan (see that
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
+38 -17
View File
@@ -30,19 +30,15 @@ namespace NecromancerTome
/// 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
/// instead of silently opening a useless empty window.
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently
/// assumed: the Bag backing each player's vault lives in a plain in-memory
/// Dictionary&lt;int, Bag&gt; in this file (PlayerVaults below), keyed by entityId. This
/// is reliable for as long as the game process keeps running (survives death/respawn/
/// relogging within one play session, confirmed by how a static field behaves) but has
/// NOT been wired into any save/load system - closing the game entirely and reloading the
/// save later will NOT bring the vault's contents back (no persistence file, no hook into
/// PersistentPlayerData or a world-save event). Building real cross-session persistence
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned
/// 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.
/// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
/// when this item shipped, and that shortfall is exactly what became the mod's first
/// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
/// disappear after leaving the game and going online again"). PlayerVaults below is still
/// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
/// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
/// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
/// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
/// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
///
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
@@ -71,8 +67,9 @@ namespace NecromancerTome
public const float MaxRange = 50f;
public const float ShoveDistance = 6f;
/// <summary>See the class-level comment above for exactly what this does and doesn't
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary>
/// <summary>Session cache only - the durable copy lives on disk, see
/// 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 bool Prefix(ItemActionData _actionData, bool _bReleased)
@@ -118,7 +115,17 @@ namespace NecromancerTome
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;
}
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 + ")");
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)
+366
View File
@@ -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);
}
}
}
}