Files
necromants-tome-7d2d-3-2/HarmonySrc/GhostTraderCommand.cs
T
AlexCubeandClaude Opus 5 20af2bbe6c Торговец снова становится призраком после выгрузки чанка
Баг со стрима: торговец, ставший чёрно-белым и полупрозрачным, наутро снова
обычный человек. Догадка была про обновление ассортимента - не подтвердилась, и
это стоит записать, потому что по часам лавки модель не трогает ВООБЩЕ ничего:
TraderData при сбросе переписывает только PrimaryInventory и lastInventoryUpdate,
а TraderArea.SetClosed ходит по блокам TraderOnOff - двери, замки, BlockLight,
динамик - и до сущности торговца не дотягивается.

Настоящая причина в EntityFactory.CreateEntityOperation.CompleteEntity:
`entity.entityId = ecd.id`, то есть сохранённый айди ВОССТАНАВЛИВАЕТСЯ. Торговцы
стримятся на подходе и точно так же выгружаются, когда игрок уходит на ночь.
Возвращается он - торговец собран заново: новый GameObject, новые рендереры,
ванильные материалы, ТОТ ЖЕ entityId. А в патче стоял HashSet<int> Ghosted -
"этот айди я уже обработал". Айди в наборе есть, свип проходит мимо, торговец
остаётся живым человеком до конца сессии. Отсюда и "на следующее утро": симптом
идёт не за часами лавки, а за чанком, в котором лавка стоит.

Ghosted стал Dictionary<int, GhostBody>, где GhostBody держит массив рендереров,
которые патч взял себе. IsIntact проверяет их: у Unity уничтоженный объект
сравнивается с null, так что подмена модели видна прямо, и та же проверка
бесплатно закрывает любую другую пересборку, не только выгрузку чанка.

Prune чистит Converted и TintedMaterials от уничтоженных объектов. Без него оба
списка росли бы на одного торговца за каждую пересборку, а Retint/Reapply ходили
бы по обломкам. Материал, выданный через renderer.materials, принадлежит
рендереру и умирает вместе с ним - одного прохода хватает на оба списка.

В лог добавлена строка "entity <id> came back with a new model" - ровно та,
которой не хватало, чтобы найти это за один заход вместо разбора в декомпиляторе.

Счётчик в necroghost переименован: со словарём он означает "торговцев под
присмотром сейчас", а не "id, которые когда-либо видели" - он теперь падает и
растёт.

Не проверено в игре: нужен заход к торговцу, выгрузка лавки и возвращение.

---

Traders go back to being ghosts after a chunk unload

Reported from the stream: a trader who had gone black-and-white and translucent
was an ordinary person again the next morning. The guess was the restock - it was
wrong, and that is worth recording, because nothing on the shop's clock touches
the model at all: TraderData's reset rewrites PrimaryInventory and
lastInventoryUpdate only, and TraderArea.SetClosed walks TraderOnOff blocks -
doors, locks, BlockLight, speaker - and never reaches the trader entity.

The real cause is in EntityFactory.CreateEntityOperation.CompleteEntity:
`entity.entityId = ecd.id`, so the saved id is RESTORED. Traders are streamed in
on approach and streamed out the same way when the player leaves for the night.
On return the trader is rebuilt from scratch - new GameObject, new renderers, the
game's own materials - carrying THE SAME entityId. The patch held a
HashSet<int> Ghosted, meaning "this id is done". The id was still in the set, the
sweep skipped him, and he stayed an ordinary person for the rest of the session.
Hence "the next morning": the symptom follows the chunk the shop sits in, not the
shop's clock.

Ghosted is now a Dictionary<int, GhostBody>, the value holding the renderers the
patch took over. IsIntact tests them: Unity's destroyed objects compare equal to
null, so a swapped model is directly visible, and the same check covers any other
rebuild for free.

Prune drops destroyed entries from Converted and TintedMaterials. Without it both
lists would grow by one trader's worth per rebuild 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.

A log line was added - "entity <id> came back with a new model" - the exact line
that was missing to find this in one visit rather than in a decompiler.

The necroghost counter was reworded: with a dictionary it means "traders held as
ghosts right now", not "ids ever seen" - it now falls as well as rises.

Not tested in game: needs a visit, a shop unload and a return.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 22:31:41 +03:00

180 lines
8.4 KiB
C#

using System.Collections.Generic;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild
/// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную
/// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken
/// model" - can only be judged by looking at him, and every step of that hunt otherwise costs
/// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because
/// traders are streamed in on approach. This collapses the loop to one line in the console.
///
/// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit
/// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the
/// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha
/// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the
/// unit and prints both back every time.
///
/// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right
/// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships.
/// A settings file would just be a second place for the answer to hide.
///
/// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes
/// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that
/// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract
/// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either:
/// GameManager calls ModManager.LoadMods() three lines before RegisterCommands().
///
/// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would
/// otherwise run where there is nothing to look at; the flag makes the server bounce it back
/// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the
/// materials and the eyes are. In single player it changes nothing.
/// </summary>
[Preserve]
public class ConsoleCmdNecroGhost : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necroghost", "necrotrader" };
}
public override string getDescription()
{
return "Necromancer's Tome: how transparent the ghost traders are, in percent.";
}
public override string getHelp()
{
return "necroghost - show the current value and mode\n" +
"necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" +
" 30 = clearly a ghost; past ~30 he stops reading as a body)\n" +
"necroghost blend - fade the body by blending (re-shades it; smooth)\n" +
"necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" +
"necroghost reset - back to the built-in default value and mode\n" +
"\n" +
"Applies to traders already in the world, immediately - walk up to one first and\n" +
"watch him while you type. Not saved: tell the mod author what you settled on.\n" +
"\n" +
"THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" +
"game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" +
"re-shades the body onto the hair's shader, which has a transparent pass, at the\n" +
"cost of the character shader's own lighting. The hair fades the same way either\n" +
"way, so it is the body you compare.\n" +
"\n" +
"If he comes apart instead of fading - teeth through the cheek, an arm through the\n" +
"chest - that is not this number, that is depth writing, and no value here will fix it.";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
if (_params.Count == 0)
{
Report("Ghost traders");
return;
}
string argument = _params[0].Trim();
if (argument.EqualsCaseInsensitive("reset"))
{
GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha;
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset");
return;
}
if (argument.EqualsCaseInsensitive("blend"))
{
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode");
return;
}
if (argument.EqualsCaseInsensitive("dither"))
{
SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode");
return;
}
if (!TryParsePercent(argument, out float percent))
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
"necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " +
"Try 'necroghost 10', or 'help necroghost'.");
return;
}
if (percent < 0f || percent > 100f)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
"necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible.");
return;
}
GhostTraderPatch.GhostAlpha = 1f - percent / 100f;
Report("Set");
// Said only when asked for, and only once the value is actually past the point where
// the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha.
if (GhostTraderPatch.GhostAlpha < 0.7f)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
" (past ~30% the silhouette stops reading as a solid body at all, which looks like " +
"a broken model for a different reason than depth writing does)");
}
}
/// <summary>Switches how the body is faded and rebuilds the traders already standing, which
/// is the expensive path - the materials have to be built again from the originals, since
/// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here.
/// Saying so out loud matters: this is the one thing in the command that is not free, and
/// flipping modes back and forth while hunting a value is the obvious way to use it.</summary>
public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix)
{
bool changed = GhostTraderPatch.BodyMode != _mode;
GhostTraderPatch.BodyMode = _mode;
int rebuilt = changed ? GhostTraderPatch.Reapply() : 0;
Report(_prefix);
if (changed && rebuilt > 0)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
" (" + rebuilt + " renderer(s) rebuilt from their original materials)");
}
}
/// <summary>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.
///
/// 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.</summary>
public static void Report(string _prefix)
{
float alpha = GhostTraderPatch.GhostAlpha;
int applied = GhostTraderPatch.Retint();
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
_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) currently held as ghosts.");
}
/// <summary>Percent out of what the user typed. StringParsers is the game's own parser and
/// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator,
/// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as
/// 125 and the trader would vanish. The comma is turned into a point before it gets there.
/// A trailing '%' is accepted because it is the obvious thing to type.</summary>
public static bool TryParsePercent(string _argument, out float _percent)
{
string text = _argument.Replace(',', '.').TrimEnd('%').Trim();
return StringParsers.TryParseFloat(text, out _percent);
}
}
}