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; + } + } + } +}