diff --git a/HarmonySrc/GhostTraderCommand.cs b/HarmonySrc/GhostTraderCommand.cs index 5714664..432656f 100644 --- a/HarmonySrc/GhostTraderCommand.cs +++ b/HarmonySrc/GhostTraderCommand.cs @@ -148,7 +148,12 @@ namespace NecromancerTome /// Current value plus what it actually reached, in both units, and which way the /// body is being faded. The count is the half that answers "did it do anything": 0 /// materials means no trader has been converted yet - they stream in on approach - not - /// that the number was refused. + /// that the number was refused. + /// + /// The trader count is "held as a ghost RIGHT NOW", not "seen this session": since the + /// 2026-09-15 fix, Ghosted is keyed by entity id but re-entered when a trader is rebuilt, + /// and a trader whose chunk has unloaded keeps his entry only until the next sweep finds + /// his model gone. So the number falls as well as rises, and that is correct. public static void Report(string _prefix) { float alpha = GhostTraderPatch.GhostAlpha; @@ -157,7 +162,7 @@ namespace NecromancerTome _prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " + alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode + ", applied to " + applied + " live material(s) across " + - GhostTraderPatch.Ghosted.Count + " trader(s) converted this session."); + GhostTraderPatch.Ghosted.Count + " trader(s) currently held as ghosts."); } /// Percent out of what the user typed. StringParsers is the game's own parser and diff --git a/HarmonySrc/GhostTraderPatch.cs b/HarmonySrc/GhostTraderPatch.cs index 336b766..1a7b1ea 100644 --- a/HarmonySrc/GhostTraderPatch.cs +++ b/HarmonySrc/GhostTraderPatch.cs @@ -67,6 +67,11 @@ namespace NecromancerTome /// 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. + /// + /// The same tick is what puts a trader BACK once the game has rebuilt him - see Ghosted, and + /// the bug of 2026-09-15 that taught this file the difference between an entity id and a + /// model. A spawn hook would not have helped there either: the entity was never re-created as + /// far as its id is concerned. /// public static class GhostTraderPatch { @@ -135,6 +140,16 @@ namespace NecromancerTome public Material[] Originals; } + /// What one trader was actually given, kept so the sweep can ask "is he STILL a + /// ghost" instead of only "have I seen this id". The renderers are the answer: a trader + /// that streams out and back in is rebuilt from scratch - new GameObject, new renderers, + /// the game's own materials - while keeping the id he was saved under, so an id on its own + /// says nothing about the model standing there now. See the Ghosted comment. + public struct GhostBody + { + public Renderer[] Renderers; + } + /// Every renderer taken over, in the order it was found. Pruned of destroyed /// renderers as they are walked; dropped wholesale when the world unloads. public static readonly List Converted = new List(); @@ -192,8 +207,29 @@ namespace NecromancerTome /// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha. public static readonly string[] FadeNameHints = { "_Fade" }; - /// Entity ids already converted. Cleared when the world unloads. - public static readonly HashSet Ghosted = new HashSet(); + /// Traders already converted, by entity id, WITH the renderers each was given. + /// Cleared when the world unloads. + /// + /// THE VALUE IS NOT DECORATION - it is the fix for "the trader stopped being a ghost the + /// next morning" (2026-09-15). This was a HashSet of ids, and an id is not enough: + /// EntityFactory restores `entity.entityId = ecd.id` from the save, so a trader who is + /// streamed out while the player is away (they are streamed IN on approach in the first + /// place - see the class comment) comes back as a BRAND NEW GameObject carrying the SAME + /// id, with the game's own materials on it. The set still held the id, the sweep skipped + /// him, and he stayed an ordinary living person for the rest of the session. + /// + /// It is NOT the restock, which was the first guess and is worth writing down as ruled + /// out: TraderData's reset rewrites PrimaryInventory and lastInventoryUpdate and touches + /// no renderer, and TraderArea.SetClosed - the whole open/close cycle - only works doors, + /// lights and speakers. Nothing on the shop's clock ever reaches the model. What does is + /// the chunk the shop sits in, which is why the symptom looks like it follows the morning: + /// the player is away for the night, the trader unloads with his chunk, and he is rebuilt + /// when they walk back. + /// + /// Holding the renderers makes the question answerable: Unity's destroyed objects compare + /// equal to null, so a trader whose model is gone is visible as such, and the same check + /// covers any other rebuild of the model for free. + public static readonly Dictionary Ghosted = new Dictionary(); /// Source shader names already described in the log, so the probe says each /// distinct thing once rather than once per trader per part. @@ -252,21 +288,84 @@ namespace NecromancerTome { continue; } - if (Ghosted.Contains(trader.entityId)) + if (Ghosted.TryGetValue(trader.entityId, out GhostBody body)) { - continue; + if (IsIntact(body)) + { + continue; + } + // His model was destroyed and rebuilt under him. Drop what is known about the + // old one before building the new, or Converted and TintedMaterials keep + // entries for renderers and materials that no longer exist. + Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + trader.entityId + + " came back with a new model - ghosting him again"); + Ghosted.Remove(trader.entityId); + Prune(); } - if (ApplyGreyscale(trader)) + if (ApplyGreyscale(trader, out GhostBody fresh)) { - Ghosted.Add(trader.entityId); + Ghosted[trader.entityId] = fresh; + } + } + } + + /// Is this trader still wearing what we put on him? False the moment any part of + /// the model we converted has been destroyed - which is what a stream-out and back in + /// looks like from here, and equally what any other rebuild of the model would look like. + /// + /// Deliberately NOT "does he have renderers we have not converted": a trader gains and + /// loses renderers in normal play (a held item, worn equipment), and treating that as a + /// rebuild would re-run the conversion on renderers already carrying our materials - whose + /// sharedMaterials hand back OUR clones, so the "originals" kept for the next mode switch + /// would be re-shaded ones with no way back. The known gap that leaves is a part of the + /// model built AFTER the first sweep reached him: it stays in colour until he next + /// reloads. Nothing like that has been seen on the six traders. + public static bool IsIntact(GhostBody _body) + { + if (_body.Renderers == null || _body.Renderers.Length == 0) + { + return false; + } + foreach (Renderer renderer in _body.Renderers) + { + if (renderer == null) + { + return false; + } + } + return true; + } + + /// Drops every entry whose Unity object the game has destroyed. Both lists are + /// session-long and keyed by nothing - without this they grow by one trader's worth of + /// renderers and materials every time a trader is rebuilt, and Retint/Reapply would be + /// walking the wreckage. A material assigned through renderer.materials is owned by that + /// renderer and dies with it, so one pass settles both. + public static void Prune() + { + for (int i = Converted.Count - 1; i >= 0; i--) + { + if (Converted[i].Renderer == null) + { + Converted.RemoveAt(i); + } + } + for (int i = TintedMaterials.Count - 1; i >= 0; i--) + { + if (TintedMaterials[i].Material == null) + { + TintedMaterials.RemoveAt(i); } } } /// 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) + /// leaves this trader unmarked and tries again on the next sweep. On true, _body carries + /// the renderers taken over, which is how the next sweep tells this trader from a rebuilt + /// one standing under the same entity id. + public static bool ApplyGreyscale(EntityTrader _trader, out GhostBody _body) { + _body = default(GhostBody); Renderer[] renderers = _trader.GetComponentsInChildren(true); if (renderers == null || renderers.Length == 0) { @@ -279,6 +378,7 @@ namespace NecromancerTome int converted = 0; int leversBefore = TintedMaterials.Count; + List taken = new List(renderers.Length); foreach (Renderer renderer in renderers) { if (renderer == null || renderer is ParticleSystemRenderer) @@ -293,11 +393,13 @@ namespace NecromancerTome } Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources }); + taken.Add(renderer); if (Convert(renderer, sources)) { converted++; } } + _body.Renderers = taken.ToArray(); // The lever count is the half that answers "will the console command reach him": // desaturation and opacity come from different properties, and the body had the first diff --git a/NecromancerHarmony.dll b/NecromancerHarmony.dll index 16f693c..1b08c8c 100644 Binary files a/NecromancerHarmony.dll and b/NecromancerHarmony.dll differ diff --git a/NecromancerHarmony.pdb b/NecromancerHarmony.pdb index 37d4945..7f437d5 100644 Binary files a/NecromancerHarmony.pdb and b/NecromancerHarmony.pdb differ