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