diff --git a/HarmonySrc/GhostTraderPatch.cs b/HarmonySrc/GhostTraderPatch.cs
new file mode 100644
index 0000000..2b76d53
--- /dev/null
+++ b/HarmonySrc/GhostTraderPatch.cs
@@ -0,0 +1,419 @@
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.Rendering;
+
+namespace NecromancerTome
+{
+ ///
+ /// 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.
+ ///
+ public static class GhostTraderPatch
+ {
+ /// Seconds between sweeps.
+ public const float SweepInterval = 2f;
+
+ /// 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.
+ public const float GhostAlpha = 0.99f;
+
+ /// Colour properties that might carry an alpha, best first.
+ public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
+
+ /// Entity ids already converted. Cleared when the world unloads.
+ public static readonly HashSet Ghosted = new HashSet();
+
+ /// Source shader names already described in the log, so the probe says each
+ /// distinct thing once rather than once per trader per part.
+ public static readonly HashSet ProbedShaders = new HashSet();
+
+ /// Desaturated copies, keyed by the texture they came from.
+ public static readonly Dictionary GreyTextures = new Dictionary();
+
+ /// 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".
+ public static readonly string[] AlbedoNameHints =
+ {
+ "_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap",
+ "_Diffuse", "_ColorMap", "_MainTexture", "_Texture"
+ };
+
+ public static float timer;
+
+ /// Called from ModEntry.InitMod.
+ 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);
+ }
+ }
+ }
+
+ /// 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.
+ public static bool ApplyGreyscale(EntityTrader _trader)
+ {
+ Renderer[] renderers = _trader.GetComponentsInChildren(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;
+ }
+
+ /// Clone of the source material - SAME shader, same everything - with only its
+ /// albedo replaced by a black-and-white copy.
+ 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;
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ /// 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.
+ 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;
+ }
+
+ /// Black-and-white copy of a texture. See the class comment for why this goes
+ /// through a RenderTexture instead of reading the source directly.
+ 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;
+ }
+
+ /// 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.
+ public static void ProbeShaderOnce(Material _source)
+ {
+ Shader shader = _source.shader;
+ string shaderName = shader != null ? shader.name : "";
+ 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 = "";
+ 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() : ""));
+ Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "") +
+ " -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "") +
+ "; tint property: " + tint + "; blend-mode properties present: " + canBlend);
+ }
+ }
+}
diff --git a/HarmonySrc/ModEntry.cs b/HarmonySrc/ModEntry.cs
index 7456e14..0f5fbe2 100644
--- a/HarmonySrc/ModEntry.cs
+++ b/HarmonySrc/ModEntry.cs
@@ -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.
diff --git a/HarmonySrc/SpatialVaultPatch.cs b/HarmonySrc/SpatialVaultPatch.cs
index dc65a6d..0f73a28 100644
--- a/HarmonySrc/SpatialVaultPatch.cs
+++ b/HarmonySrc/SpatialVaultPatch.cs
@@ -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<int, Bag> 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;
- /// 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.
+ /// 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.
public static readonly Dictionary PlayerVaults = new Dictionary();
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)
diff --git a/HarmonySrc/SpatialVaultPersistence.cs b/HarmonySrc/SpatialVaultPersistence.cs
new file mode 100644
index 0000000..ac6b1e6
--- /dev/null
+++ b/HarmonySrc/SpatialVaultPersistence.cs
@@ -0,0 +1,366 @@
+using System;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text;
+using HarmonyLib;
+using UnityEngine;
+
+namespace NecromancerTome
+{
+ ///
+ /// 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.
+ ///
+ public static class SpatialVaultPersistence
+ {
+ /// 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.
+ public const byte PayloadVersion = 1;
+
+ /// 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.
+ public class VaultSlot
+ {
+ public Bag Bag;
+ }
+
+ /// 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.
+ public static readonly ConditionalWeakTable AttachedVaults =
+ new ConditionalWeakTable();
+
+ ///
+ /// 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.
+ ///
+ public static Bag LastLoadedVault;
+
+ /// 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.
+ public static string lastSaveLogged;
+
+ /// 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.
+ 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();
+ }
+ }
+
+ /// Null when the payload holds no vault or is a version we do not know.
+ 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 });
+ }
+
+ /// 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.
+ 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();
+ }
+ }
+
+ /// Live player -> save file: take the vault along.
+ [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);
+ }
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+ }
+ }
+
+ /// Appends the vault after everything vanilla wrote - to disk via Save, or to the
+ /// wire via WriteNetwork.
+ [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);
+ }
+ }
+ }
+
+ /// 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".
+ [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);
+ }
+ }
+ }
+}
diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll
index 4bd0df6..b050062 100644
Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ
diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb
index 4a00c5e..7d512a7 100644
Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ
diff --git a/NecromancerTEPersistence.dll b/NecromancerTEPersistence.dll
index 8d64336..2a1a9d3 100644
Binary files a/NecromancerTEPersistence.dll and b/NecromancerTEPersistence.dll differ
diff --git a/NecromancerTEPersistence.pdb b/NecromancerTEPersistence.pdb
index 8a0b8dd..cd06c43 100644
Binary files a/NecromancerTEPersistence.pdb and b/NecromancerTEPersistence.pdb differ
diff --git a/TEPersistenceSrc/SpatialVaultBlobIO.cs b/TEPersistenceSrc/SpatialVaultBlobIO.cs
new file mode 100644
index 0000000..a56c771
--- /dev/null
+++ b/TEPersistenceSrc/SpatialVaultBlobIO.cs
@@ -0,0 +1,99 @@
+using System.IO;
+
+namespace NecromancerTome
+{
+ ///
+ /// 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.
+ ///
+ public static class SpatialVaultBlobIO
+ {
+ /// ASCII "NECROVLT" as one int64 - distinctive enough that stray bytes will not
+ /// be mistaken for our block.
+ public const long Magic = 0x4E4543524F564C54L;
+
+ /// Magic (8) + length (4).
+ 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);
+ }
+
+ /// 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.
+ 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;
+ }
+ }
+ }
+}