8 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5 4801341676 Версия 1.0.1
Номер выбран под то, что видят игроки: на Nexus опубликована 1.0, а 2.0.0
в ModInfo проставлялась для внутренней работы и наружу не выходила.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 19:55:50 +03:00
AlexCubeandClaude Opus 5 29431990f6 Хранилище браслета переживает перезапуск; торговцы чёрно-белые
Исправляет первый баг-репорт мода на Nexus (youkia96581, 11.09.2026):
"Items stored in the space bracelet will disappear after leaving the game
and going online again". Причина была записана в коде как нерешённая:
PlayerVaults - обычный статический Dictionary, save/load не существовало.

ХРАНИЛИЩЕ ТЕПЕРЬ ЖИВЁТ В PlayerDataFile, рядом с рюкзаком игрока. Так
решено после вопроса пользователя "почему не сделать принцип как у ящика?":
ящик хранит вещи тем, что они лежат в чанке (у TileEntity единственный
конструктор TileEntity(Chunk)), а браслету нужен был дом в чём-то, что
движок и так сохраняет. Четыре постфикса - FromPlayer/Write/Read/ToPlayer,
блоб с магией "NECROVLT" и явной длиной дописывается после всего
ванильного. Байтовая часть - в сателлитной сборке: PooledBinaryWriter.Write
не резолвится из основного проекта (CS7069), как и у PyramidWardWriteHelper.

Два дефекта, найденные и убитые по дороге живыми тестами:

1. ModEvents.WorldShuttingDown приходит ПЕРЕД финальным сохранением игрока
   (GameManager.SaveAndCleanupWorld: событие на IL_0026, SaveLocalPlayerData
   на IL_00c4). Обработчик, чистивший там кэш, затирал хранилище на каждом
   корректном выходе. Обработчик убран; свежесть решает авторитетность
   ToPlayer, а не таймер.
2. Пустой сессионный кэш трактовался как "хранилища нет" и записывался
   поверх настоящего. Путь восстановления имеет право не сработать, удалять
   он права не имеет - добавлена страховка LastLoadedVault.

Проверено в игре: положил -> вышел -> запустил заново -> вещи на месте,
блоб на 54 байта сверен в .ttp побайтово.

ТОРГОВЦЫ (npcTraderJoel/Rekt/Bob/Hugh/Jen) - чёрно-белые. Шейдер НЕ
подменяется: материал клонируется со своим шейдером, меняется только
текстура альбедо на обесцвеченную копию, так что свет, нормали и скиннинг
остаются движковыми. Альбедо ищется обходом свойств шейдера, а не по имени:
тело - Game/Character/_Albedo, волосы - Game/Autodesk/_MainTex. Плюс 1%
прозрачности с сохранением _ZWrite. Опрос раз в 2 с, потому что торговцы
стримятся на подходе, а Джен собирается в рантайме.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 19:54:48 +03:00
AlexCubeandClaude Opus 5 a5f8592903 Пространственный браслет: 4 слота под модификации
Сами моды будут позже. Схема списана с Ножа некроманта: тег noMods
отсекает все ванильные моды, тег necroBracelet зарезервирован под будущие
свои. canHaveCosmetic намеренно не добавлен - именно он создаёт слот под
краски.

У effect_group НЕТ tiered="false", и это главное: ItemClass.HasQuality -
это Effects.IsOwnerTiered(), а ItemValue.FireEvent выходит по
if (!HasQuality) return; ДО обхода Modifications[]. На нетированном
предмете слоты появились бы, моды вставлялись бы, и ни один
triggered_effect внутри них не сработал бы. Качество при этом не
показывается: ShowQuality по умолчанию false и намеренно не задан.

Старый браслет из сейва слотов не получит - размер Modifications[]
фиксируется при создании предмета.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 19:54:22 +03:00
AlexCubeandClaude Opus 5 ccf58a2ec8 "Могильный покой": термозащита 5 -> 50
Прямое указание пользователя. Единица здесь - градусы сдвига уличной
температуры к комфортным 70, а не проценты, и в PlayerEntityStats стоит
min/max-ограничение: с 50 любая температура в пределах 50 градусов от 70
подтягивается к 70 целиком. То есть примерно от 20 до 120 по шкале игры
это полный иммунитет, а не "сильная защита". Прежние 5 были уровнем одной
детали брони с T3-подкладкой.

Описание в Localization.csv править не потребовалось: там "strong
protection from both freezing and heat" без конкретных цифр.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 19:54:22 +03:00
AlexCubeandClaude Opus 5 a645c53ff3 Пространственный браслет: в руке пусто, блочный хват
Свёрток-«коробочка на бечёвке» (parcelGenericPrefab + HoldType=31,
унаследованные от Петли вора) для браслета выглядел нелепо. Теперь в
руке не видно ничего, только кулак, повёрнутый вниз.

Связка списана с ванильного vehicleMinibikePlaceable (items.xml:13385),
где те же две строки стоят подряд: HoldType="7" + HoldingItemHidden="true".

Найдено декомпиляцией Assembly-CSharp 3.2.0 через Mono.Cecil, который
лежит прямо в игре (Mods/0_TFP_Harmony/Mono.Cecil.dll):

- HoldType=7 - это и есть блочный хват: его ставит ItemClassBlock..ctor,
  а в blocks.xml свойство HoldType не встречается ни разу, то есть каждый
  блок в игре держится именно этим значением. Костет - это 70, запасной
  вариант не понадобился.
- HoldingItemHidden - штатное свойство ItemClass (PropHoldingItemHidden в
  .cctor, ParseBool в Init), а Inventory.setHoldingItemTransform в конце
  делает SetActive(!HoldingItemHidden). Гаснет только модель в руке -
  иконка инвентаря и предмет на земле не трогаются.
- Пустой меш поставить нельзя: ItemClass.CloneModel подставляет заглушку
  leather.fbx, если ассет не загрузился. Поэтому Meshfile оставлен
  затычкой, а DropMeshfile - ванильный мешок, как у минибайка.
- Действия предмета не задеты: ItemActionEat читает HoldType только ради
  AnimationDelay[HoldType].RayCast, который равен 0f и у 31, и у 7;
  ExecuteAction, за которую держится SpatialVaultPatch, его не читает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 17:21:20 +03:00
AlexCubeandClaude Opus 5 ab1b3eadaf Версия 2.0: свои модели ножа и крови, своя краска блоков через патч атласа
Нож некроманта и Кровь некроманта получили собственные модели, а Пирамида
духов - собственную поверхность. Три разных способа, каждый выбран по тому,
как устроен сам предмет.

НОЖ И КРОВЬ - свои префабы в бандлах мода.
Форма записи "#@modfolder(...)?prefab" подтверждена живым примером; геометрия
у обоих ванильная, меняются материал и текстура. Текстуры генерируются
скриптами (см. _private/tools), а не рисуются: правка вида сводится к правке
констант и одному запуску.

Нож: состаренная кость, почти чёрная обмотка, пурпур во впадинах, плюс слой
под ручную роспись рун - он подмешивается в альбедо и в эмиссию, поэтому руны
светятся. Слой пользовательский, генератор его никогда не перезаписывает.
Положение росписи посчитано по геометрии: развёртка выгружена из меша, и
подобрано смещение, при котором вся роспись ложится на одну плоскую грань.
Раньше она перегибалась через кромку, обращённую к игроку.

Кровь: банка вместо мешка. Заодно чинится расхождение, жившее с самого начала -
описание предмета говорило "Банка, наполненная кровью", а наследуемый
medicalBloodBag показывал sackPrefab, обычный мешок. Жидкость перерисована по
маскам мешей, а не перекрашена тинтом: тинт предмета на этот меш не действует
вовсе, у шейдера Game_EntityTintMaskSSS выигрывает _Color материала.

ПИРАМИДА - своя краска в атласе блоков через собственный Harmony-патч.
Ваниль своих текстур блоков не умеет: Texture у блока это индекс в готовом
атласе, а запись краски несёт только TextureId/PaintCost/Group/SortIndex и
никогда путь к картинке. Поэтому CustomBlockPaintPatch дописывает наш слой в
массивы атласа на лету.

Путь через свою модель (Shape="ModelEntity") пробовался и отложен: там
остались нерешёнными столкновения и маджента на дальних экземплярах. Краска
лучше тем, что блок остаётся обычным Shape="New" - со всеми работающими
столкновениями, наведением по E и правильной посадкой, - а поверхность у него
своя. Побочно краска доступна кисточкой под именем "Некротический прах".

Параметры атласа не угаданы, а замерены в игре (BlockAtlasProbePatch): 512x512,
DXT1 для альбедо и DXT5 для нормалей со specular, 10 мип-уровней, массивы
нечитаемые - отсюда GPU-копирование и явные настройки импорта текстур.
Зонд оставлен намеренно: номер краски в blocks.xml (608) это длина ванильного
uvMapping, и после обновления игры он может сдвинуться - зонд печатает
фактические числа при каждом запуске.

Патчи обёрнуты целиком: они работают внутри загрузки игры, где вылетевшее
исключение срывает шаг загрузки.

Resources/necropyramid в коммит не идёт - на него никто не ссылается.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W5F3AVwsusZHMqPBcSQcVJ
2026-09-10 16:28:59 +03:00
AlexCubeandClaude Opus 5 026e006c9c Убран мёртвый код проигрывания видео; заготовка своей модели ножа
PlayBlackPortalVideoLegacy и BlackPortalVideoPath ниоткуда не вызывались
с 2026-09-09, когда концовку заменили слайдами. Разборы Pause/PlayVideo и
синтаксиса @modfolder перенесены в NoteFlashbackPatch - единственное место,
где эти API ещё вызываются. Перекрёстные ссылки в FinalSlides поправлены,
устаревшее упоминание StayVideoPath/ReturnVideoPath убрано.

В items.xml у necroWpnBladeNecroKnife добавлена закомментированная строка
Meshfile под будущий бандл Resources/necroknife - включать вместе с самим
бандлом, не раньше. Там же снято старое сомнение про TintColor: ваниль сама
тинтит тот же boneShivPrefab, значит механизм на оружии работает.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y3pwyLpTrwz41qBjyjzNSA
2026-09-10 02:07:31 +03:00
Alex CubeandClaude Opus 5 6e1f42f053 Добавлена ссылка на страницу Nexus Mods
Мод опубликован: nexusmods.com/7daystodie/mods/12547. Ссылка вписана в README
и в оба языковых блока описания для сайта, первой строкой в списке ссылок -
для читателя это главная точка загрузки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MaNro5hAGTzcQ7rJNN2tCX
2026-09-09 21:49:38 +03:00
25 changed files with 1804 additions and 138 deletions
+1
View File
@@ -122,3 +122,4 @@ necroFinalStayText,XUi,Menu,,,,"You took the army of the dead under your command
necroFinalReturnTitle,XUi,Menu,,,,The Quiet Harbour,,Der stille Hafen,El puerto tranquilo,Le havre tranquille,Il porto tranquillo,静かな港,고요한 항구,Cicha przystań,O porto tranquilo,Тихая гавань,Sessiz Liman,静港,靜港
necroFinalReturnText,XUi,Menu,,,,"You ordered the lich to lead the dead away into the forests, so that people could come out of their shelters and start surviving on their own, the way you once survived. If they manage, good. If they do not... then such is their fate. And you went back: to the world where you had already settled in and found your quiet harbour.",,"Du hast dem Lich befohlen, die Toten fort in die Wälder zu führen damit die Menschen aus ihren Verstecken treten und selbst zu überleben beginnen können, so wie du einst überlebt hast. Schaffen sie es, gut. Schaffen sie es nicht ... dann ist das eben ihr Schicksal. Und du bist zurückgegangen: in die Welt, in der du dich bereits eingerichtet und deinen stillen Hafen gefunden hast.","Ordenaste al liche que se llevara a los muertos a los bosques, para que la gente pudiera salir de sus refugios y empezar a sobrevivir por su cuenta, igual que sobreviviste tú en su día. Si lo consiguen, bien. Si no... será su destino. Y tú regresaste: al mundo donde ya te habías asentado y habías encontrado tu puerto tranquilo.","Tu as ordonné à la liche d'emmener les morts au loin dans les forêts pour que les gens puissent sortir de leurs abris et commencer à survivre seuls, comme tu as survécu autrefois. S'ils y parviennent, tant mieux. Sinon... c'est leur destin. Et toi, tu es reparti : vers le monde où tu t'étais déjà installé et où tu avais trouvé ton havre tranquille.","Hai ordinato al lich di condurre i morti via nelle foreste, perché la gente potesse uscire dai rifugi e iniziare a sopravvivere da sé, come un tempo sei sopravvissuto tu. Se ce la fanno, bene. Se non ce la fanno... allora è il loro destino. E tu sei tornato indietro: nel mondo dove ti eri già sistemato e avevi trovato il tuo porto tranquillo.",あなたはリッチに命じ、不死者をすべて森へ連れ去らせた。人々が避難所から出て、かつてのあなたと同じように、自らの力で生き延び始められるように。やり遂げるならそれでいい。できなければ……それが彼らの定めというだけだ。そしてあなたは戻っていった。すでに身を落ち着け、静かな港を見つけたあの世界へ。,"당신은 리치에게 명하여 언데드를 모두 숲으로 데려가게 했다. 사람들이 피난처에서 나와, 한때 당신이 그랬듯 스스로 살아남기 시작할 수 있도록. 해낸다면 좋은 일이다. 해내지 못한다면... 그것이 그들의 운명일 뿐이다. 그리고 당신은 돌아갔다. 이미 자리를 잡고 자신만의 고요한 항구를 찾아낸 그 세계로.","Rozkazałeś liczowi wyprowadzić umarłych w lasy żeby ludzie mogli wyjść ze schronów i zacząć przetrwać o własnych siłach, tak jak kiedyś przetrwałeś ty. Poradzą sobie dobrze. Nie poradzą... cóż, taki ich los. A ty wróciłeś: do świata, w którym zdążyłeś się urządzić i znaleźć swoją cichą przystań.","Você ordenou ao lich que levasse os mortos embora para as florestas - para que as pessoas pudessem sair dos abrigos e começar a sobreviver por conta própria, como você mesmo sobreviveu um dia. Se conseguirem, ótimo. Se não... então é o destino delas. E você voltou: para o mundo onde já havia se estabelecido e encontrado o seu porto tranquilo.","Вы приказали личу увести нежить в леса — чтобы люди вышли из убежищ и начали выживать сами, как когда-то выживали вы. Справятся — хорошо. Не справятся... значит, такова их судьба. А вы вернулись обратно: в мир, где успели обустроиться и найти свою тихую гавань.","Liche, ölüleri alıp ormanlara götürmesini emrettin - insanlar sığınaklarından çıkıp, bir zamanlar senin hayatta kaldığın gibi kendi başlarına hayatta kalmaya başlayabilsinler diye. Başarırlarsa ne âlâ. Başaramazlarsa... demek ki kaderleri buymuş. Sen ise geri döndün: çoktan yerleşip kendi sessiz limanını bulduğun o dünyaya.",你命令巫妖将亡灵尽数带往森林深处——好让人们走出避难所,像你当年那样,靠自己活下去。若他们撑得住,那很好;若撑不住……那便是他们的命。而你回去了:回到那个你早已安顿下来、寻得自己静港的世界。,你命令巫妖將亡靈盡數帶往森林深處——好讓人們走出避難所,像你當年那樣,靠自己活下去。若他們撐得住,那很好;若撐不住……那便是他們的命。而你回去了:回到那個你早已安頓下來、尋得自己靜港的世界。
necroFinalBtnTheEnd,XUi,Menu,,,,The End,,Ende,Fin,Fin,Fine,おわり,,Koniec,Fim,Конец,Son,全剧终,全劇終
txName_NecroAsh,painting,Texture,,,,Necrotic Ash,,Nekrotische Asche,Ceniza necrótica,Cendre nécrotique,Cenere necrotica,,,,,Некротический прах,,,
1 Key File Type UsedInMainMenu NoTranslate KeepLoaded english Context / Alternate Text german spanish french italian japanese koreana polish brazilian russian turkish schinese tchinese
122 necroFinalReturnTitle XUi Menu The Quiet Harbour Der stille Hafen El puerto tranquilo Le havre tranquille Il porto tranquillo 静かな港 고요한 항구 Cicha przystań O porto tranquilo Тихая гавань Sessiz Liman 静港 靜港
123 necroFinalReturnText XUi Menu You ordered the lich to lead the dead away into the forests, so that people could come out of their shelters and start surviving on their own, the way you once survived. If they manage, good. If they do not... then such is their fate. And you went back: to the world where you had already settled in and found your quiet harbour. Du hast dem Lich befohlen, die Toten fort in die Wälder zu führen – damit die Menschen aus ihren Verstecken treten und selbst zu überleben beginnen können, so wie du einst überlebt hast. Schaffen sie es, gut. Schaffen sie es nicht ... dann ist das eben ihr Schicksal. Und du bist zurückgegangen: in die Welt, in der du dich bereits eingerichtet und deinen stillen Hafen gefunden hast. Ordenaste al liche que se llevara a los muertos a los bosques, para que la gente pudiera salir de sus refugios y empezar a sobrevivir por su cuenta, igual que sobreviviste tú en su día. Si lo consiguen, bien. Si no... será su destino. Y tú regresaste: al mundo donde ya te habías asentado y habías encontrado tu puerto tranquilo. Tu as ordonné à la liche d'emmener les morts au loin dans les forêts – pour que les gens puissent sortir de leurs abris et commencer à survivre seuls, comme tu as survécu autrefois. S'ils y parviennent, tant mieux. Sinon... c'est leur destin. Et toi, tu es reparti : vers le monde où tu t'étais déjà installé et où tu avais trouvé ton havre tranquille. Hai ordinato al lich di condurre i morti via nelle foreste, perché la gente potesse uscire dai rifugi e iniziare a sopravvivere da sé, come un tempo sei sopravvissuto tu. Se ce la fanno, bene. Se non ce la fanno... allora è il loro destino. E tu sei tornato indietro: nel mondo dove ti eri già sistemato e avevi trovato il tuo porto tranquillo. あなたはリッチに命じ、不死者をすべて森へ連れ去らせた。人々が避難所から出て、かつてのあなたと同じように、自らの力で生き延び始められるように。やり遂げるならそれでいい。できなければ……それが彼らの定めというだけだ。そしてあなたは戻っていった。すでに身を落ち着け、静かな港を見つけたあの世界へ。 당신은 리치에게 명하여 언데드를 모두 숲으로 데려가게 했다. 사람들이 피난처에서 나와, 한때 당신이 그랬듯 스스로 살아남기 시작할 수 있도록. 해낸다면 좋은 일이다. 해내지 못한다면... 그것이 그들의 운명일 뿐이다. 그리고 당신은 돌아갔다. 이미 자리를 잡고 자신만의 고요한 항구를 찾아낸 그 세계로. Rozkazałeś liczowi wyprowadzić umarłych w lasy – żeby ludzie mogli wyjść ze schronów i zacząć przetrwać o własnych siłach, tak jak kiedyś przetrwałeś ty. Poradzą sobie – dobrze. Nie poradzą... cóż, taki ich los. A ty wróciłeś: do świata, w którym zdążyłeś się urządzić i znaleźć swoją cichą przystań. Você ordenou ao lich que levasse os mortos embora para as florestas - para que as pessoas pudessem sair dos abrigos e começar a sobreviver por conta própria, como você mesmo sobreviveu um dia. Se conseguirem, ótimo. Se não... então é o destino delas. E você voltou: para o mundo onde já havia se estabelecido e encontrado o seu porto tranquilo. Вы приказали личу увести нежить в леса — чтобы люди вышли из убежищ и начали выживать сами, как когда-то выживали вы. Справятся — хорошо. Не справятся... значит, такова их судьба. А вы вернулись обратно: в мир, где успели обустроиться и найти свою тихую гавань. Liche, ölüleri alıp ormanlara götürmesini emrettin - insanlar sığınaklarından çıkıp, bir zamanlar senin hayatta kaldığın gibi kendi başlarına hayatta kalmaya başlayabilsinler diye. Başarırlarsa ne âlâ. Başaramazlarsa... demek ki kaderleri buymuş. Sen ise geri döndün: çoktan yerleşip kendi sessiz limanını bulduğun o dünyaya. 你命令巫妖将亡灵尽数带往森林深处——好让人们走出避难所,像你当年那样,靠自己活下去。若他们撑得住,那很好;若撑不住……那便是他们的命。而你回去了:回到那个你早已安顿下来、寻得自己静港的世界。 你命令巫妖將亡靈盡數帶往森林深處——好讓人們走出避難所,像你當年那樣,靠自己活下去。若他們撐得住,那很好;若撐不住……那便是他們的命。而你回去了:回到那個你早已安頓下來、尋得自己靜港的世界。
124 necroFinalBtnTheEnd XUi Menu The End Ende Fin Fin Fine おわり Koniec Fim Конец Son 全剧终 全劇終
125 txName_NecroAsh painting Texture Necrotic Ash Nekrotische Asche Ceniza necrótica Cendre nécrotique Cenere necrotica Некротический прах
+95 -2
View File
@@ -66,10 +66,103 @@
<property class="TEFeaturePyramidWard" />
</property>
<property name="Material" value="Msteel_shapes" />
<!-- ВЕРНУТО НА Shape="New" 2026-09-10 по решению пользователя: «пока верни пирамиду,
которая была у нас изначально».
Своя модель со своей текстурой требует Shape="ModelEntity", и на этом пути осталось
два невылеченных дефекта - разбор и все замеры в BACKLOG.md, раздел про пирамиду:
- нет столкновений: при "New" их считает воксельная система блока, а "ModelEntity"
берёт от модели, и одного BoxCollider на корне в слое 16 не хватило;
- дальние экземпляры рисуются фиолетовым (маджента = не найден шейдер), ближние
при этом верны; добавленный LODGroup положения не исправил.
Всё, что сделано для того пути, сохранено и работает: генератор текстуры
_private/tools/make_pyramid_textures.py, сборка _private/Extracted/ShapesUnityProject,
бандл Resources/necropyramid. Чтобы вернуться к нему, достаточно заменить Shape на
ModelEntity и раскомментировать строку Model ниже. -->
<property name="Shape" value="New" />
<!-- Своя модель отключена вместе с возвратом на Shape="New" (см. комментарий выше).
Строка сохранена: она рабочая, ею подключается наш бандл. -->
<property name="Model" value="@:Shapes/pyramid.fbx" />
<property name="Texture" value="356" />
<property name="TintColor" value="8A4142" />
<!-- <property name="Model" value="#@modfolder(NecromancerTome):Resources/necropyramid?necroPyramidPrefab.prefab" /> -->
<!-- ModelOffset - ПОЛОЖЕНИЕ МОДЕЛИ, и задавать его надо именно здесь.
Меш формы из shapes-бандла сделан под воксельную систему: его границы лежат не вокруг
начала координат, а в углу (центр -0.50, 0.13, -0.50 при размере 0.51 x 0.26 x 0.50).
Как ModelEntity такая модель вылезает из своего куба - что и наблюдалось: «поднялась,
вершиной упирается в верхний угол».
Сначала это лечили сдвигом детей ВНУТРИ префаба. Модель встала верно, но по блоку
по-прежнему нельзя было попасть и не работало наведение по E: о сдвиге знал только
префаб, а игра держала свой объём для попаданий там, где модель была бы без сдвига.
Игрок целился в одно, а попадал мимо.
Ваниль двигает модель именно этим свойством (у верстака стоит "0,.5,0"), и его игра
учитывает целиком - и в отрисовке, и в попаданиях. Значение посчитано скриптом сборки
по фактическим границам меша: по X и Z в центр куба, по Y основанием на нижнюю грань.
Если меш заменят, скрипт напечатает новое значение в лог. -->
<!-- ModelOffset нужен только при ModelEntity; при "New" положение задаёт сама форма.
Значение верное, посчитано скриптом сборки - сохранено для возврата.
<property name="ModelOffset" value="0.5,-0.495,0.5" /> -->
<!-- ПОКРАСКА ВМЕСТО СВОЕЙ ТЕКСТУРЫ, 2026-09-10. Мысль пользователя: «в игре есть
кисточка, позволяющая перекрашивать блоки, можем ли мы наложить текстуру через
покраску, только заранее?» Можем - именно это свойство Texture и делает.
Data/Config/painting.xml сопоставляет краски из кисточки с номерами текстур атласа
(поле TextureId), а Texture у блока задаёт краску заранее. Доступно 156 красок;
выписаны с названиями в _private/Extracted/paints.txt.
Было 356 (txName_Steel_wall, «оцинкованная стальная стена») - выбор достался от
steelShapes вместе с парой Material/Texture и к праху отношения не имел.
Пробовали 11 (txName_Gravel, «гравий») - в игре прочиталось как земля.
Сейчас 552 (txName_GraniteBlack, «гранит чёрный»): тёмный камень ближе к
спрессованному праху, чем крупная осыпь гравия.
СВОЮ текстуру сюда подставить НЕЛЬЗЯ, проверено по коду игры: у записи краски есть
только TextureId, PaintCost, Group, SortIndex - поля с путём к своей картинке нет.
TextureId это индекс в уже собранном атласе, а сам атлас лежит в
blocktextureatlases_assets_all.bundle среди Addressables игры (рядом с
TerrainTextures), и крючка для подмены его модом не нашлось. Своя текстура
возможна только через путь ModelEntity со своим бандлом - см. BACKLOG.md.
Подбирать краску удобнее всего кисточкой прямо в игре: доступны все 156, перезапуск
не нужен. Найденный номер прописывается сюда.
СЕЙЧАС ЭТИ ДВА СВОЙСТВА НЕ РАБОТАЮТ. Блок снова на Shape="ModelEntity" со своей
моделью (2026-09-10, после того как префаб довели до вида ванильного): при
ModelEntity поверхность даёт материал префаба, а не атлас, и Texture с TintColor
не участвуют. Оставлены нетронутыми, чтобы возврат к покраске был ровно двумя
правками - Shape на "New" и переключить строку Model ниже.
TintColor - множитель поверх краски. Взят пепельно-серый с уходом в фиолет, в тон
остальному мод-набору. Прежний 8A4142 давал ржаво-красный.
Texture принимает и ШЕСТЬ значений через запятую - по одному на грань (в ванили есть
например value="195,570,570,570,570,570"). Если захочется основание отличать от
скатов - делается здесь же, без всякого бандла.
Хорошие запасные варианты из того же списка: 606 concrete_broken (потрескавшийся
бетон), 552 GraniteBlack (чёрный гранит), 443 Rust_black (чёрная ржавчина),
385 Green_rusty_metal (зелёная ржавчина, в тон вкраплениям). -->
<!-- СВОЯ КРАСКА, 2026-09-10. Это уже НЕ ванильная краска из атласа: текстуру пирамиды
добавляет в атлас наш собственный Harmony-патч, HarmonySrc/CustomBlockPaintPatch.cs.
Блок при этом остаётся обычным Shape="New" - со всеми работающими столкновениями,
наведением по E и правильной посадкой, - а поверхность у него своя.
608 - это НОМЕР ЗАПИСИ В uvMapping, а не номер краски. Патч печатает его в лог при
каждом запуске: «uvMapping entry 608 points at slice 407». Краска дополнительно
регистрируется под именем txName_NecroAsh («Некротический прах») и занимает слот 13,
но блоку нужен именно номер записи.
ХРУПКОЕ МЕСТО: 608 - это длина uvMapping ванильной игры, то есть наша запись просто
дописывается в конец. Число верно, пока атлас игры не изменился; после обновления игры
оно может сдвинуться. Патч печатает фактический номер в лог, поэтому расхождение видно
сразу - сверяться при обновлениях.
TintColor убран намеренно: цвет несёт сама текстура, а тинт - множитель, он бы её
исказил. Тот же вывод, что уже сделан по ножу и по банке крови.
<property name="TintColor" value="C8BCD2" /> -->
<property name="Texture" value="608" />
<!-- Real generated art (exch/pyramidOfSpirit.png, copied to
UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no
CustomIconTint alongside it (same lesson as every other real icon in this mod,
+17 -7
View File
@@ -131,11 +131,21 @@
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
T3 4.6->6 на ОДИН элемент брони, а элементов четыре. Взято 5 - примерно уровень
одной детали брони с T3-подкладкой, и ровно то число, которое ваниль использовала во
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh (там 5 на холод и 5 на
жару, но двумя РАЗНЫМИ модами; здесь оба в одном, что щедрее - но это стоит слота из
четырёх и работает только с ножом в руках, см. ниже). Крутить это число - одна правка.
T3 4.6->6 на ОДИН элемент брони, а элементов четыре.
ЗНАЧЕНИЕ 5 -> 50, 2026-09-13, прямое указание пользователя ("по факту она поднимает
сопротивление всего на 5, а надо на 50"). Изначально стояло 5 - примерно уровень одной
детали брони с T3-подкладкой, и ровно то число, которым ваниль пользуется во
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh. Это было осознанно
скромно; пользователь хочет иначе, и его решение тут главнее моей балансной оценки.
ЧТО 50 ОЗНАЧАЕТ НА САМОМ ДЕЛЕ, раз единица - градусы, а не проценты (формула ниже):
любая уличная температура в пределах 50 градусов от комфортных 70 подтягивается К 70
ЦЕЛИКОМ, потому что там стоит min/max-ограничение. То есть от 20 до 120 по шкале игры
это не "сильная защита", а полный иммунитет: и снежная вершина, и пустынный полдень
перестают быть угрозой. Это примерно в 10 раз больше, чем даёт набор брони с
T3-подкладками на всех четырёх деталях. Записано не в укор, а чтобы через месяц не
пришлось гадать, почему термометр перестал что-либо значить.
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
комфортной, а не проценты (PlayerEntityStats, декомпиляция):
@@ -173,8 +183,8 @@
<property name="SellableToTrader" value="false"/>
<effect_group tiered="false">
<passive_effect name="HypothermalResist" operation="base_add" value="5"/>
<passive_effect name="HyperthermalResist" operation="base_add" value="5"/>
<passive_effect name="HypothermalResist" operation="base_add" value="50"/>
<passive_effect name="HyperthermalResist" operation="base_add" value="50"/>
</effect_group>
</item_modifier>
+219 -15
View File
@@ -214,7 +214,31 @@
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
</triggered_effect>
<!-- Generic stone-on-flesh thud, deliberately left UNCONDITIONAL: it fires on every
impact (ground, wall, zombie) so a miss still sounds like something landed. -->
<triggered_effect trigger="onProjectileImpact" action="PlaySound" sound="stonehitorganic"/>
<!-- The charm's OWN cue - fires only on a hit that actually charms, gated by the exact
same "other is a zombie" requirement the AddBuff above uses, so it can never fire on
a miss. Requirements on a PlaySound effect are the vanilla stun baton pattern
(Data/Config/items.xml:4005 - IsAlive/EntityTagCompare on target="other"), not
invented here; onProjectileImpact populating "other" with the hit entity is proven
by the AddBuff right above, which already works in game.
target="other" is meant to play the clip FROM the zombie, i.e. positional at the
point of impact instead of at the thrower's head (the attribute itself is vanilla -
items.xml:5514 uses target="self"). If it turns out silent in game, drop just the
attribute: the effect then plays on self and the gating still holds.
THE MOD'S OWN SOUND, the first one in the whole mod that is not a borrowed vanilla
id: "necroSpiritStoneHit" is defined in this mod's Config/sounds.xml and its clip
lives in Resources/necrosounds, built from the Unity project (see that file's header
for why a plain wav next to the XML cannot work). If the sound is missing in game,
the failure is SILENT - look for "AudioManager LoadAudio failed to load audio clip"
in the game log, and note that the generic stonehitorganic above will still play, so
"I heard something" is not proof this one fired. -->
<triggered_effect trigger="onProjectileImpact" action="PlaySound" target="other" sound="necroSpiritStoneHit">
<requirement name="EntityTagCompare" target="other" tags="zombie"/>
</triggered_effect>
</effect_group>
</item>
</append>
@@ -893,8 +917,61 @@
whether melee weapons with ShowQuality="true" (inherited from the base knife)
render TintColor the same way. If it's still not black after this deploy, that's
the next thing to dig into (possibly needs ShowQuality or a cosmetic-slot
workaround), not a guess to make blind right now. -->
<property name="TintColor" value="0, 0, 0"/>
workaround), not a guess to make blind right now.
СОМНЕНИЕ СНЯТО 2026-09-10 по самой ванили: meleeWpnBladeT0BoneKnife задаёт себе
TintColor 107, 107, 71 (Data/Config/items.xml:2419) на том же самом
boneShivPrefab, то есть слот тинта у этого меша рабочий и на оружии с
ShowQuality тоже применяется. Копать тут больше нечего; осталось только
посмотреть глазами, достаточно ли чёрный получается клинок. -->
<!-- TintColor ОТКЛЮЧЁН 2026-09-10 вместе с переходом на свою текстуру.
TintColor - это МНОЖИТЕЛЬ цвета меша, а 0,0,0 множит в чистый чёрный. Он был нужен,
пока нож носил ванильную бежевую кость: другого способа затемнить клинок не было.
Теперь цвет несёт своя текстура necroKnife_d.png, и тот же множитель погасил бы
всё нарисованное в ноль - вместе с пурпурным свечением в альбедо.
Иконка от этого не зависит: она отдельный арт (CustomIcon выше), CustomIconTint
здесь никогда не стоял. И на иконке клинок именно КОСТЯНОЙ, а не чёрный, так что
чёрный множитель ей вдобавок противоречил.
Побочно это снимает неоднозначность теста трубы: красный клинок мог быть виден
только за счёт эмиссии, если тинт всё-таки давил альбедо нашего меша. Без тинта
вопроса больше нет.
<property name="TintColor" value="0, 0, 0"/> -->
<!-- СВОЯ МОДЕЛЬ. ВКЛЮЧЕНА 2026-09-10 вместе с бандлом Resources/necroknife (2.19 МБ,
собран Unity 2022.3.62f2 в batch-режиме; внутри necroKnifePrefab.prefab, три меша
boneShiv_LOD0/1/2, текстуры boneShiv_d/_n и материал necroKnife.mat - бандл
самодостаточный, проверено по составу).
ВНИМАНИЕ: в бандле сейчас ТЕСТОВЫЙ вид - ярко-красный материал с эмиссией. Это
проверка трубы, а не финальная модель. Красный выбран потому, что ванильный нож
бежевый и по нему не отличить, загрузился наш бандл или подставилась ваниль;
эмиссия - потому что TintColor ниже стоит 0,0,0, и если игра множит тинт на
материал нашего меша, красный альбедо ушёл бы в чёрный и тест ничего бы не показал.
Настоящая текстура рисуется поверх _private/Extracted/boneShiv_d.png после того,
как труба подтвердится.
Синтаксис: "#" - грузить из бандла, "@modfolder(NecromancerTome):" - путь от
корня этого мода, "?" отделяет путь к префабу ВНУТРИ бандла. Форма собрана из
двух подтверждённых кусков: ваниль пишет "#Entities/Trees?SnakeweedPrefab.prefab"
(Data/Config/blocks.xml), а поддержка "@modfolder:" лежит в Assembly-CSharp.
ФОРМА ПОДТВЕРЖДЕНА ЖИВЫМ ПРИМЕРОМ 2026-09-10: с этой строкой тестовый красный
нож появился в руке. Значит и запись работает, и бандл из папки мода читается,
и Standard-шейдер из Unity 2022.3.62f2 игра отрисовывает. Больше не гипотеза -
этой же формой можно подключать любые следующие свои модели.
necroknife - имя AssetBundle, заданное префабу в Unity; necroKnifePrefab.prefab -
имя префаба ВНУТРИ бандла. Оба должны совпасть с тем, что реально собралось, иначе
предмет останется без модели молча. Пересборка - меню NecromancerTome -> Ctrl+Shift+B
либо batch: Unity.exe -batchmode -nographics -quit -projectPath <проект>
-executeMethod NecroKnifeSetup.SetupAndBuild. Игру после каждой пересборки
перезапускать: моды и их бандлы читаются только при старте.
Геометрия остаётся ванильной, меняются материал и текстура, поэтому HoldType,
посадка в руке и анимации от базового ножа продолжают подходить без правок. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroknife?necroKnifePrefab.prefab"/>
<!-- MOD SLOTS ADDED 2026-09-07 (user request: "добавь в нож слоты для
модификаций... модификации там будут особые, именно для ножа
@@ -1235,7 +1312,39 @@
same reasoning as every other hand-drawn icon in this mod (Dog/Insect summon
books, etc.) - don't recolor finished art. -->
<property name="CustomIcon" value="NecromantsBlood"/>
<property name="TintColor" value="60, 0, 10"/>
<!-- СВОЯ БАНКА С КРОВЬЮ, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое
заменяем на кровавый цвет, с фиолетовыми оттенками»).
Заодно чинится расхождение текста и модели: описание предмета
(resourceNecromancerBloodDesc) с самого начала говорит «Банка, наполненная кровью
самого некроманта», а наследуемый medicalBloodBag показывает
@:Other/Items/Misc/sackPrefab.prefab - обычный мешок. Банка вернее и по механике:
рецепт и так требует пустую банку (recipes.xml, NecromancerBloodPatch.cs).
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Сначала пробовали дёшево, без бандла:
ванильный префаб чая плюс TintColor. Проверка 2026-09-10 показала, что банка
осталась чаем из золотарника - тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ ВООБЩЕ.
У шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала (у чая
жёлтый, 166,133,37), и свойство предмета его не перебивает. Поэтому TintColor здесь
не задаётся совсем: он ничего не даёт и только вводил бы в заблуждение.
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная и
густая, с плёнкой на стекле. Поэтому жидкость ПЕРЕРИСОВАНА по яркости, а не
перекрашена множителем - генератор _private/tools/make_necroblood_textures.py.
HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани
(звук удара и осколки при разбитии). Без них банка держалась бы как мешок.
ЧТО СМОТРЕТЬ ГЛАЗАМИ. Материал собран на встроенном Standard в режиме Fade: родной
шейдер переиспользовать нельзя, AssetRipper выгрузил шейдеры заглушками. У Standard
альфа текстуры - это прозрачность, поэтому она задана осознанно: стекло
полупрозрачное, жидкость плотная. Банка должна читаться как стекло с густой кровью,
а не как матовый сосуд. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
<property name="HoldType" value="3"/>
<property name="Material" value="Mglass"/>
<property name="EconomicValue" value="0"/>
</item>
</append>
@@ -1288,7 +1397,29 @@
rest of the mod's hand-drawn icons). -->
<append xpath="/items">
<item name="braceletSpatialVault">
<property name="Tags" value="T0,weapon,attPerception"/>
<!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами
модификации реализуем потом"). Two tags, exactly the scheme necroWpnBladeNecroKnife
already proved on 2026-09-07 - see that item's own comment for the full
decompiled reasoning:
noMods - blocks every vanilla mod. All 87 vanilla item_modifiers that
declare blocked_tags at all list noMods among them; the
remaining 24 cannot reach this item anyway (10 dyes and 7 drone
mods need a cosmetic slot or the drone tag, 2 are quest items,
1 needs perkArchery, 3 are CreativeMode Test/Dev). "noMods"
means nothing in code - it is purely a naming convention used
inside other items' blocked_tags.
necroBracelet - the positive half, reserved for the mods that come later. Every
future bracelet mod MUST declare
installable_tags="necroBracelet": a modifier with no
installable_tags at all fits ANYTHING (XUiM_AssembleItem short-
circuits on InstallableTags.IsEmpty), so forgetting it produces
the exact opposite of what is wanted.
Deliberately NOT adding canHaveCosmetic: that tag alone is what creates the paint
slot (ItemValue's constructor sizes CosmeticMods by it), and the knife had to have
it removed for precisely this reason. No tag, no slot, no dyes. -->
<property name="Tags" value="T0,weapon,attPerception,noMods,necroBracelet"/>
<!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
@@ -1316,19 +1447,64 @@
(bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
<property name="CustomIcon" value="ProstranstvennoeHranilische"/>
<!-- Same "seed"-style grip as braceletThiefLoop originally had (see that item's own
comment for the full history) - foodCropYuccaFruit's own HoldType="31" +
parcelGenericPrefab.prefab. Unlike Thief's Loop, this item never touches
Class="Zoom" (both its actions are Class="Eat"), so it never hit the
"Attachments" transform error that forced Thief's Loop onto a real weapon mesh -
no reason to change this one's mesh too. TintColor changed to green 2026-08-30
per direct request. -->
<!-- МЕШ И ХВАТ, 2026-09-13. Просьба в два захода: сперва "пусть будет камень, а
хват давай сделаем как когда пытаешься ставить какой-нибудь блок", затем
уточнение - "браслет это браслет... в идеале меш камня вообще убрать". Было:
свёрток-«семечко» parcelGenericPrefab.prefab (коробочка, перевязанная бечёвкой -
для браслета нелепо) + HoldType="31", и то и другое унаследовано от Петли вора.
ИТОГ: в руке НЕТ НИЧЕГО, только кулак. Пустой префаб собирать не пришлось - в
движке есть готовое свойство, и вся связка целиком списана с ванильного
vehicleMinibikePlaceable (items.xml:13385), у которого стоят ровно те же две
строки подряд: HoldType="7" + HoldingItemHidden="true".
HoldType="7" - это и есть блочный хват ("кулак вниз, как будто держишь руль"),
не угаданный номер. Декомпилировано Mono.Cecil'ом из Assembly-CSharp 3.2.0
(сам Mono.Cecil.dll лежит в Mods/0_TFP_Harmony, отдельный декомпилятор не нужен):
ItemClassBlock..ctor -> HoldType = new DataItem<int>(7)
AnimationDelayData.AnimationDelay[7] =
new AnimationDelays(0, 0f, 0f, .31f, .31f, true) <- последний флаг TwoHanded
В blocks.xml свойства HoldType нет ни разу (0 вхождений), то есть КАЖДЫЙ блок в
игре держится именно семёркой из этого конструктора. Костет
(meleeWpnKnucklesT0LeatherKnuckles) - это HoldType="70", запасной вариант не
понадобился.
HoldingItemHidden="true" - штатное свойство ItemClass, а не трюк:
ItemClass..cctor заводит PropHoldingItemHidden = "HoldingItemHidden",
ItemClass.Init читает его через StringParsers.ParseBool, а
Inventory.setHoldingItemTransform в самом конце делает
holdingItemTransform.gameObject.SetActive(!HoldingItemHidden). Гасится ТОЛЬКО
модель в руке: иконка в инвентаре (своя рисованная ProstranstvennoeHranilische)
и мешок на земле не трогаются, действия предмета живут в ItemActionEat и от
этого GameObject не зависят.
Пустой меш поставить было НЕЛЬЗЯ, и это проверено, а не предположено:
ItemClass.CloneModel, если имя меша пустое и ассет не загрузился, подставляет
заглушку "@:Other/Items/Crafting/leather.fbx" - в руке оказался бы кусок кожи.
Единственный ванильный предмет вообще без Meshfile - meleeHandMaster (голые
руки), и он выкручивается через Canhold="false", что нам не подходит: браслет
надо держать, чтобы им пользоваться.
Meshfile оставлен камнем как безобидная затычка (в руке он скрыт, а для
MeshPurpose World/Local/Preview что-то иметь надо), DropMeshfile - ванильный
мешок sack_droppedPrefab, ровно тем же приёмом и по той же причине, что у
vehicleMinibikePlaceable: выброшенный предмет должно быть видно на земле, а
своей модели у него нет. HandMeshfile убран за ненадобностью.
Про HoldType и действия: единственное место, где ItemActionEat вообще читает
HoldType, - AnimationDelay[HoldType].RayCast (в PercentDone и IsActionRunning),
и он равен 0f и у старого 31, и у нового 7 (InitStatic заполняет все 100 слотов
нулями, ItemClassBlock переписывает слот 7, оставляя RayCast нулём).
ExecuteAction, за которую держится SpatialVaultPatch.cs, HoldType не читает
вовсе - проверено сканом IL по всей сборке. -->
<property name="Material" value="Morganic"/>
<property name="Meshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
<property name="HandMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
<property name="TintColor" value="30, 200, 60"/>
<property name="HoldType" value="31"/>
<property name="HoldType" value="7"/>
<property name="HoldingItemHidden" value="true"/>
<property name="Weight" value="0"/>
<property name="Stacknumber" value="1"/>
<property name="EconomicValue" value="0"/>
@@ -1341,6 +1517,34 @@
<property name="Class" value="Eat"/>
<property name="Delay" value="0.3"/>
</property>
<!-- FOUR MOD SLOTS. The count is a passive_effect, not an item property - same shape
the knife uses, and flat rather than a per-quality list because quality means
nothing on this item.
NOTE THE MISSING ATTRIBUTE: this effect_group has NO tiered="false", and that is
the entire point. ItemClass.HasQuality is literally Effects.IsOwnerTiered(), and
ItemValue.FireEvent bails out with `if (!HasQuality) return;` BEFORE it walks
Modifications[] - so on an untiered item the slots still appear and still accept
mods, and not one triggered_effect inside them ever fires. That silent failure
cost a whole debugging session on the knife on 2026-09-07; it is not repeated
here. The slots themselves would work either way (Modifications is allocated
unconditionally, earlier), which is exactly what makes the failure so quiet.
Quality is not SHOWN, though: ShowQuality is a separate property that defaults to
false (vanilla sets it to true explicitly on the ~80 items that want a quality
bar), and it is deliberately left unset here. The item behaves as tiered for the
mod system and still reads as a plain bracelet in the UI.
FOR THE MODS THEMSELVES, WHEN THEY GET WRITTEN: give each one its OWN
modifier_tags. XUiC_ItemPartStack.CanSwap counts already-installed mods whose
modifier_tags intersect the one being installed and refuses at
num >= ItemClass.MaxModsAllowed, which defaults to 1 - so a shared tag like
"necroBraceletMod" across all four would leave exactly one of these four slots
usable. -->
<effect_group name="braceletSpatialVault">
<passive_effect name="ModSlots" operation="base_set" value="4"/>
</effect_group>
</item>
</append>
</config>
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.Text;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// DIAGNOSTIC - measures the game's opaque block texture atlas and logs what it finds. Adds
/// nothing and changes nothing.
///
/// KEPT ON PURPOSE, though it started as throwaway reconnaissance for CustomBlockPaintPatch.
/// That patch appends our paint to the end of the atlas, so the block's Texture number in
/// blocks.xml (608 today) is simply "however many entries vanilla had". A game update that
/// grows the atlas moves it. This probe prints the real numbers on every load, which is what
/// turns that drift from a silent wrong texture into one line in the log.
///
/// WHY THIS EXISTS. The Pyramid of Spirits should ship with its own surface, but vanilla has
/// no way to add one: a block's Texture property is an INDEX into a prebuilt atlas, and the
/// atlas itself lives in blocktextureatlases_assets_all.bundle. Confirmed by reading the
/// game's own strings - a paint entry carries only TextureId/PaintCost/Group/SortIndex, never
/// a path to an image. So the only way in is to extend the atlas at runtime from a Harmony
/// patch.
///
/// Extending it means building a bigger Texture2DArray, copying every existing slice across
/// and appending ours. That REQUIRES knowing the array's exact width, height, format and
/// mipmap count - a slice that disagrees on any of those cannot be copied in. None of it can
/// be known statically, hence this probe: measure first, write the real patch second.
///
/// TWO LESSONS FROM THE FIRST ATTEMPT, both paid for in a broken load:
///
/// 1. A THROWING POSTFIX BREAKS THE GAME'S LOADING. The first version dereferenced
/// BlockTextureData.list without checking it, threw, and the log answered with
/// "XML loader: Executing post load step on 'materials.xml' failed". A probe must be
/// incapable of harm, so everything here is wrapped and nothing is allowed to escape.
///
/// 2. THIS RUNS BEFORE THE PAINT TABLE EXISTS. ReloadTextureArrays fires during
/// MeshDescription.Init, and the log shows painting.xml loading well after it - so
/// BlockTextureData.list is still null at that point. Hence the probe reports several
/// times instead of once: the early call shows the atlas as loaded, later calls show it
/// once the rest of the game has caught up.
///
/// </summary>
[HarmonyPatch(typeof(MeshDescription), "ReloadTextureArrays")]
public static class BlockAtlasProbePatch
{
const int MaxReports = 4;
static int reports;
static void Postfix()
{
if (reports >= MaxReports) return;
reports++;
// Never let a measurement break a load: the game calls this from inside its own
// XML post-load step, and an escaping exception aborts that step.
try
{
LogOpaqueAtlas(reports);
}
catch (Exception e)
{
Debug.LogWarning("[NecromancerTome] BlockAtlasProbe: measurement #" + reports +
" failed harmlessly: " + e.Message);
}
}
public static void LogOpaqueAtlas(int report)
{
var sb = new StringBuilder();
sb.AppendLine("[NecromancerTome] BlockAtlasProbe #" + report + ": opaque block atlas");
if (MeshDescription.meshes == null)
{
sb.AppendLine(" MeshDescription.meshes is null - too early");
Debug.Log(sb.ToString());
return;
}
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
if (mesh == null)
{
sb.AppendLine(" MESH_OPAQUE is null - too early");
Debug.Log(sb.ToString());
return;
}
var atlas = mesh.textureAtlas as TextureAtlasBlocks;
if (atlas == null)
{
sb.AppendLine(" textureAtlas is " + (mesh.textureAtlas == null
? "null" : mesh.textureAtlas.GetType().Name) + ", expected TextureAtlasBlocks");
Debug.Log(sb.ToString());
return;
}
sb.AppendLine(" uvMapping entries: " +
(atlas.uvMapping == null ? "null" : atlas.uvMapping.Length.ToString()));
Describe(sb, "diffuse ", atlas.diffuseTexture);
Describe(sb, "normal ", atlas.normalTexture);
Describe(sb, "specular", atlas.specularTexture);
// A new paint needs an unused index in BlockTextureData.list. The table is filled
// from painting.xml, which loads AFTER the textures - so on the early call this is
// still null, and that is expected rather than a fault.
if (BlockTextureData.list == null)
{
sb.AppendLine(" paint table: not built yet (painting.xml loads later)");
}
else
{
int used = 0, free = 0;
for (int i = 0; i < BlockTextureData.list.Length; i++)
{
if (BlockTextureData.list[i] == null) free++; else used++;
}
sb.AppendLine(" paint slots: " + used + " used, " + free + " free, " +
BlockTextureData.list.Length + " total");
}
Debug.Log(sb.ToString());
}
static void Describe(StringBuilder sb, string label, Texture texture)
{
if (texture == null)
{
sb.AppendLine(" " + label + ": null");
return;
}
var arr = texture as Texture2DArray;
if (arr == null)
{
sb.AppendLine(" " + label + ": " + texture.GetType().Name +
" (expected Texture2DArray) " + texture.width + "x" + texture.height);
return;
}
// depth = how many slices are already in the array; ours would become index `depth`,
// and every number below has to be matched exactly by our own texture.
sb.AppendLine(" " + label + ": " + arr.width + "x" + arr.height +
" slices=" + arr.depth +
" format=" + arr.format +
" graphicsFormat=" + arr.graphicsFormat +
" mips=" + arr.mipmapCount +
" readable=" + arr.isReadable);
}
}
}
+321
View File
@@ -0,0 +1,321 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Adds the mod's own paint to the game's opaque block texture atlas, so the Pyramid of
/// Spirits can ship with a surface that does not exist in vanilla.
///
/// WHY A PATCH IS THE ONLY WAY. A block's Texture property is an INDEX into a prebuilt
/// atlas; a paint entry in painting.xml carries TextureId/PaintCost/Group/SortIndex and
/// never a path to an image, and the atlas itself is compiled into
/// blocktextureatlases_assets_all.bundle. Nothing in the XML layer can introduce new image
/// data, so the array has to be extended at runtime.
///
/// WHY THIS HOOK. CreateBlockTextures is the coroutine that reads painting.xml. Hooking its
/// completion is deliberate and was learned the hard way: an earlier probe ran from
/// MeshDescription.ReloadTextureArrays and found BlockTextureData.list still null, because
/// the texture arrays load BEFORE painting.xml. By the time this coroutine finishes, both the
/// arrays and the paint table exist.
///
/// THE NUMBERS THIS RELIES ON were measured in-game rather than assumed (BlockAtlasProbe,
/// 2026-09-10): the opaque atlas holds 407 slices of 512x512 with a full 10-level mip chain,
/// diffuse as DXT1 and normal/specular as DXT5, and all three arrays are non-readable. Two
/// consequences drive the code below - our textures must match those numbers exactly, and
/// every copy must go through the GPU, since a non-readable array cannot be read back.
///
/// SAFETY. Everything is wrapped: this runs inside the game's own XML loading, and an
/// escaping exception aborts that step - which is exactly how a careless earlier version
/// produced "XML loader: Executing post load step on 'materials.xml' failed". If anything
/// here fails, the mod logs it and leaves the game exactly as it was.
/// </summary>
public static class CustomBlockPaintPatch
{
/// <summary>Bundle we ship the paint textures in, relative to the mod folder.</summary>
const string BundlePath = "Resources/necroatlas";
const string DiffuseAsset = "Assets/NecroAtlas/atlas_necroPyramid_d.png";
const string NormalAsset = "Assets/NecroAtlas/atlas_necroPyramid_n.png";
const string SpecularAsset = "Assets/NecroAtlas/atlas_necroPyramid_m.png";
/// <summary>Name the paint is registered under; blocks.xml refers to the resulting id.</summary>
public const string PaintName = "txName_NecroAsh";
/// <summary>Paint id handed out by the game once registration succeeds, -1 while unset.
/// Logged on success so it can be written into blocks.xml.</summary>
public static int AssignedPaintId = -1;
static bool alreadyRan;
/// <summary>
/// Patch the coroutine's MoveNext. A coroutine compiles into a hidden state-machine
/// class, so the method that actually runs is MoveNext, not CreateBlockTextures itself -
/// AccessTools.EnumeratorMoveNext resolves it for us.
/// </summary>
[HarmonyPatch]
static class CreateBlockTexturesHook
{
static IEnumerable<MethodBase> TargetMethods()
{
MethodBase coroutine = AccessTools.Method(
typeof(BlockTexturesFromXML), "CreateBlockTextures");
if (coroutine == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
"BlockTexturesFromXML.CreateBlockTextures not found - paint not added");
yield break;
}
MethodBase moveNext = AccessTools.EnumeratorMoveNext(coroutine);
if (moveNext == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " +
"could not resolve the coroutine's MoveNext - paint not added");
yield break;
}
yield return moveNext;
}
// __result == false means the enumerator is done: the XML has been read in full.
static void Postfix(bool __result)
{
if (__result || alreadyRan) return;
alreadyRan = true;
try
{
AddPaint();
}
catch (Exception e)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: failed, game left " +
"untouched: " + e);
}
}
}
static void AddPaint()
{
if (GameManager.IsDedicatedServer)
{
Debug.Log("[NecromancerTome] CustomBlockPaint: dedicated server, textures skipped");
return;
}
MeshDescription mesh = MeshDescription.meshes[MeshDescription.MESH_OPAQUE];
var atlas = mesh == null ? null : mesh.textureAtlas as TextureAtlasBlocks;
if (atlas == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: opaque atlas unavailable");
return;
}
AssetBundle bundle = LoadBundle();
if (bundle == null) return;
try
{
var diffuse = bundle.LoadAsset<Texture2D>(DiffuseAsset);
var normal = bundle.LoadAsset<Texture2D>(NormalAsset);
var specular = bundle.LoadAsset<Texture2D>(SpecularAsset);
if (diffuse == null || normal == null || specular == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle is missing one " +
"of the three textures - nothing added");
return;
}
Describe("our diffuse ", diffuse);
Describe("our normal ", normal);
Describe("our specular", specular);
int slice = Append(ref atlas.diffuseTexture, diffuse, "diffuse");
Append(ref atlas.normalTexture, normal, "normal");
Append(ref atlas.specularTexture, specular, "specular");
if (slice < 0) return;
mesh.TexDiffuse = atlas.diffuseTexture;
mesh.TexNormal = atlas.normalTexture;
mesh.TexSpecular = atlas.specularTexture;
mesh.ReloadTextureArrays(false);
int textureId = RegisterUvMapping(atlas, slice);
RegisterPaint(textureId);
}
finally
{
// Keep the loaded textures alive: only the bundle wrapper is released.
bundle.Unload(false);
}
}
/// <summary>
/// Give the new slice an entry in uvMapping and return its index - that index is what a
/// block's Texture property in blocks.xml actually refers to.
///
/// The entry is CLONED from an existing plain opaque paint rather than built field by
/// field. UVRectTiling carries more than a slice number (tiling, block size, material
/// flags), and copying a known-good neighbour keeps every one of those correct without
/// guessing at fields we have never inspected. Only the slice index is changed.
/// </summary>
static int RegisterUvMapping(TextureAtlasBlocks atlas, int slice)
{
// 356 is txName_Steel_wall - an ordinary full-block opaque paint, which is exactly
// the shape of entry we want.
const int TemplateTextureId = 356;
if (atlas.uvMapping == null || atlas.uvMapping.Length <= TemplateTextureId)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: uvMapping too small to " +
"clone a template from - paint not registered");
return -1;
}
int textureId = atlas.uvMapping.Length;
Array.Resize(ref atlas.uvMapping, textureId + 1);
UVRectTiling tile = atlas.uvMapping[TemplateTextureId];
// Только индекс слоя: имени у UVRectTiling нет, оно живёт в BlockTextureData.
tile.index = slice;
atlas.uvMapping[textureId] = tile;
Debug.Log("[NecromancerTome] CustomBlockPaint: uvMapping entry " + textureId +
" points at slice " + slice + " (cloned from " + TemplateTextureId + ")");
return textureId;
}
/// <summary>
/// Register the paint itself, so it has a name, shows up in the paint brush, and can be
/// referred to by name. The block only needs the texture id, but a nameless texture with
/// no paint entry would be invisible to the rest of the game.
/// </summary>
static void RegisterPaint(int textureId)
{
if (textureId < 0) return;
if (BlockTextureData.list == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: paint table missing - " +
"texture added but not named");
return;
}
int free = -1;
for (int i = 0; i < BlockTextureData.list.Length; i++)
{
if (BlockTextureData.list[i] == null) { free = i; break; }
}
if (free < 0)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: no free paint slot - " +
"texture added but not named");
return;
}
var data = new BlockTextureData
{
ID = free,
Name = PaintName,
LocalizedName = Localization.Get(PaintName),
TextureID = (ushort)textureId,
Group = "txGroupMasonry",
PaintCost = 1,
SortIndex = 0,
Hidden = false,
};
data.Init();
AssignedPaintId = free;
Debug.Log("[NecromancerTome] CustomBlockPaint: paint registered, slot " + free +
", texture id " + textureId + " -> put Texture=\"" + textureId +
"\" on the block in blocks.xml");
}
static AssetBundle LoadBundle()
{
if (ModEntry.Instance == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: mod path unknown");
return null;
}
string path = Path.Combine(ModEntry.Instance.Path, BundlePath);
if (!File.Exists(path))
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle not found at " + path);
return null;
}
AssetBundle bundle = AssetBundle.LoadFromFile(path);
if (bundle == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: bundle failed to load: " + path);
}
return bundle;
}
/// <summary>
/// Rebuild a texture array one slice larger and put our texture in the new last slot.
/// Returns the new slice index, or -1 if the arrays disagree on anything that makes a
/// copy impossible.
/// </summary>
static int Append(ref Texture target, Texture2D ours, string label)
{
var src = target as Texture2DArray;
if (src == null)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label +
" is not a Texture2DArray - skipped");
return -1;
}
if (ours.width != src.width || ours.height != src.height)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " size mismatch, " +
"atlas is " + src.width + "x" + src.height + " but ours is " +
ours.width + "x" + ours.height + " - skipped");
return -1;
}
if (ours.graphicsFormat != src.graphicsFormat)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " format mismatch, " +
"atlas is " + src.graphicsFormat + " but ours is " + ours.graphicsFormat +
" - skipped");
return -1;
}
if (ours.mipmapCount != src.mipmapCount)
{
Debug.LogWarning("[NecromancerTome] CustomBlockPaint: " + label + " mip mismatch, " +
"atlas has " + src.mipmapCount + " but ours has " + ours.mipmapCount +
" - skipped");
return -1;
}
int slice = src.depth;
var grown = new Texture2DArray(src.width, src.height, slice + 1,
src.graphicsFormat, UnityEngine.Experimental.Rendering.TextureCreationFlags.MipChain,
src.mipmapCount);
grown.name = src.name + "+necro";
grown.wrapMode = src.wrapMode;
grown.filterMode = src.filterMode;
grown.anisoLevel = src.anisoLevel;
// GPU-side copy: the game's arrays are non-readable, so nothing can be pulled back
// to the CPU. CopyTexture moves whole slices with their mip chains.
for (int i = 0; i < slice; i++) Graphics.CopyTexture(src, i, grown, i);
Graphics.CopyTexture(ours, 0, grown, slice);
target = grown;
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " grown from " + slice +
" to " + (slice + 1) + " slices");
return slice;
}
static void Describe(string label, Texture2D tex)
{
Debug.Log("[NecromancerTome] CustomBlockPaint: " + label + " " +
tex.width + "x" + tex.height + " format=" + tex.format +
" graphicsFormat=" + tex.graphicsFormat + " mips=" + tex.mipmapCount);
}
}
}
+6 -8
View File
@@ -44,7 +44,7 @@ namespace NecromancerTome
/// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена
/// FinishEnding). Как и вся остальная UI-часть этого мода, сцена
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
/// ограничение самой ванили, а не мода.
/// </summary>
@@ -258,14 +258,12 @@ namespace NecromancerTome
/// "Конец".
///
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
/// из финала"). Сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая
/// код по кускам.
///
/// Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm УДАЛЕНЫ ИЗ МОДА
/// 2026-09-09 перед публикацией: все три были побайтовой копией ванильного
/// из финала"). Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm тогда же
/// удалены из мода перед публикацией: все три были побайтовой копией ванильного
/// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя.
/// Настоящее видео класть под тем же именем.
/// Мёртвый код проигрывания видео (PortalStonePatch.PlayBlackPortalVideoLegacy и
/// константа с путём) убран 2026-09-10 - живой пример того же вызова, если видео
/// понадобится вернуть, остался в NoteFlashbackPatch.cs.
///
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
/// текст показывается сразу.</summary>
+419
View File
@@ -0,0 +1,419 @@
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
namespace NecromancerTome
{
/// <summary>
/// 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.
/// </summary>
public static class GhostTraderPatch
{
/// <summary>Seconds between sweeps.</summary>
public const float SweepInterval = 2f;
/// <summary>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.</summary>
public const float GhostAlpha = 0.99f;
/// <summary>Colour properties that might carry an alpha, best first.</summary>
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
/// <summary>Entity ids already converted. Cleared when the world unloads.</summary>
public static readonly HashSet<int> Ghosted = new HashSet<int>();
/// <summary>Source shader names already described in the log, so the probe says each
/// distinct thing once rather than once per trader per part.</summary>
public static readonly HashSet<string> ProbedShaders = new HashSet<string>();
/// <summary>Desaturated copies, keyed by the texture they came from.</summary>
public static readonly Dictionary<Texture, Texture2D> GreyTextures = new Dictionary<Texture, Texture2D>();
/// <summary>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".</summary>
public static readonly string[] AlbedoNameHints =
{
"_MainTex", "_Albedo", "_BaseMap", "_BaseColorMap", "_AlbedoMap", "_DiffuseMap",
"_Diffuse", "_ColorMap", "_MainTexture", "_Texture"
};
public static float timer;
/// <summary>Called from ModEntry.InitMod.</summary>
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);
}
}
}
/// <summary>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.</summary>
public static bool ApplyGreyscale(EntityTrader _trader)
{
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(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;
}
/// <summary>Clone of the source material - SAME shader, same everything - with only its
/// albedo replaced by a black-and-white copy.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>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.</summary>
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;
}
/// <summary>Black-and-white copy of a texture. See the class comment for why this goes
/// through a RenderTexture instead of reading the source directly.</summary>
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;
}
/// <summary>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.</summary>
public static void ProbeShaderOnce(Material _source)
{
Shader shader = _source.shader;
string shaderName = shader != null ? shader.name : "<null shader>";
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 = "<none>";
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() : "<none>"));
Debug.Log("[NecromancerTome] GhostTraderPatch: shader '" + shaderName + "' - albedo: " + (chosen ?? "<none>") +
" -> " + (chosenTexture != null ? chosenTexture.name + " " + chosenTexture.width + "x" + chosenTexture.height : "<none>") +
"; tint property: " + tint + "; blend-mode properties present: " + canBlend);
}
}
}
+15
View File
@@ -10,11 +10,26 @@ namespace NecromancerTome
/// </summary>
public class ModEntry : IModApi
{
/// <summary>The mod's own folder, kept from InitMod so patches can find files we ship
/// (currently Resources/necroatlas for the custom block paint). Nothing else knows where
/// the mod lives - the game hands it over exactly once, right here.</summary>
public static Mod Instance;
public void InitMod(Mod _modInstance)
{
Instance = _modInstance;
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.
+7
View File
@@ -44,6 +44,13 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- AssetBundle.LoadFromFile for CustomBlockPaintPatch.cs (2026-09-10) - the mod ships its
own block paint textures in Resources/necroatlas, and Unity keeps bundle loading in its
own module rather than CoreModule. -->
<Reference Include="UnityEngine.AssetBundleModule">
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AssetBundleModule.dll</HintPath>
<Private>false</Private>
</Reference>
<!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
channel-cancel (2026-08-29) - the game's own input layer, not something this mod
previously needed to touch directly. -->
+23 -6
View File
@@ -7,10 +7,21 @@ namespace NecromancerTome
/// <summary>
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
/// opened, pause the game and play a flashback). Reuses the exact pause+video pipeline
/// already built and tested for the Black Portal Stone (see PortalStonePatch.cs's
/// ActivateBlackPortal - GameManager.Instance.Pause/XUiC_VideoPlayer.PlayVideo, both APIs
/// decompiled there already, same reasoning applies unchanged here).
/// opened, pause the game and play a flashback). Built on the pause+video pipeline first
/// written for the Black Portal Stone; since the finale switched to text slides
/// (FinalSlides) and its dead video code was removed 2026-09-10, this patch is the only
/// place in the mod that still calls either API, so both write-ups live here now:
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
/// documented no-op in multiplayer rather than something silently broken.
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
/// already auto-closes on a bad file on its own, this is just a second, player-facing
/// way out.
///
/// FINDING THE RIGHT PATCH POINT: noteDuke01 has no custom C# class of its own - it's a
/// plain Class="Eat" item (items.xml) whose entire "reading" experience is a vanilla trick:
@@ -42,8 +53,9 @@ namespace NecromancerTome
/// flashback -> read text -> confirm", rather than overlapping the video with the text box.
///
/// VIDEO FILE: Video/DukeNoteFlashback.mp4 - the user's real flashback clip (delivered
/// 2026-08-30 as exch/flashbback.mp4), kept as .mp4 rather than renamed to .webm like the
/// Black Portal placeholder: Unity's VideoPlayer component (confirmed by decompiling
/// 2026-08-30 as exch/flashbback.mp4), and the only video the mod still ships. Kept as
/// .mp4 rather than renamed to .webm like the since-deleted Black Portal placeholder:
/// Unity's VideoPlayer component (confirmed by decompiling
/// XUiV_Video - it wraps a plain UnityEngine.Video.VideoPlayer) natively decodes MP4/H.264 on
/// Windows via Media Foundation, and re-labeling an actual MP4 container as .webm would just
/// make it fail to decode (VP8/VP9 container expected, not H.264) - not decompiled/proven
@@ -54,6 +66,11 @@ namespace NecromancerTome
[HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback
{
/// <summary>"@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
/// confirm - looks for "@modfolder(&lt;mod name&gt;):" and substitutes the mod's real
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
/// DisplayName).</summary>
public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
/// <summary>Guards the re-entrant call this patch makes to the very method it patches
+19 -78
View File
@@ -159,45 +159,23 @@ namespace NecromancerTome
TeleportToBedroll(player);
}
/// <summary>Black portal confirmation + fullscreen video, user request 2026-08-30
/// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается
/// видео"). Real APIs, both decompiled directly:
/// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) -
/// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect
/// confirmations, etc). ShowOkCancel/ShowConfirmCancel exist too but hardcode their
/// button caption keys ("xuiOk"/"xuiCancel"/"btnConfirm") - ShowCustom's
/// _setupCallback is the only variant that lets the two buttons be captioned
/// "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys, confirmed
/// against Data/Config/Localization.csv), matching the user's literal "да/нет"
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/
/// ShowConfirmCancel themselves use internally - Buttons[1] is left unused, same as
/// vanilla's own 2-button dialogs.
/// - GameManager.Instance.Pause(bool) - decompiled GameManager.updatePauseState: sets
/// Time.timeScale=0 for real, but ONLY takes effect in singleplayer (an SP-only check
/// baked into vanilla itself, not a limitation added by this mod) - a deliberate,
/// documented no-op in multiplayer rather than something silently broken.
/// - XUiC_VideoPlayer.PlayVideo(xui, VideoData, skippable, onFinished) - opens the same
/// fullscreen "VideoPlayer" window vanilla's own TFP intro/menu-background videos use.
/// Decompiled XUiV_Video confirms video playback isn't gated by Time.timeScale, so it
/// keeps playing correctly while paused. skippable=true (Cancel key) so a broken/
/// missing video file can't soft-lock the player - XUiV_Video.OnVideoErrorReceived
/// already auto-closes on a bad file on its own, this is just a second, player-facing
/// way out.
/// <summary>Black portal confirmation dialog, user request 2026-08-30
/// ("диалоговое окно... вы уверены"). XUiC_MessageBoxWindowGroup.ShowCustom(xui, title,
/// text, icon, setupCallback, ...) - the same generic Yes/No popup vanilla itself uses (its
/// own delete-item/disconnect confirmations, etc), decompiled directly. ShowOkCancel/
/// ShowConfirmCancel exist too but hardcode their button caption keys ("xuiOk"/"xuiCancel"/
/// "btnConfirm") - ShowCustom's _setupCallback is the only variant that lets the two buttons
/// be captioned "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys,
/// confirmed against Data/Config/Localization.csv), matching the user's literal "да/нет"
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/ShowConfirmCancel
/// themselves use internally - Buttons[1] is left unused, same as vanilla's own 2-button
/// dialogs.
///
/// VIDEO FILE: Video/BlackPortal.webm NO LONGER SHIPS WITH THE MOD. It used to be a
/// byte-for-byte copy of vanilla's own TFP_Intro.webm (from
/// 7DaysToDie_Data/StreamingAssets/Video/), placed there 2026-08-30 as a test stand-in
/// ("Пока файл видео замени заглушкой") - deleted 2026-09-09 before the public release,
/// since redistributing a game asset is not ours to do. This path is dead until a real
/// video is dropped in under the same name; the method below is legacy anyway (the
/// finale plays text epilogues now, see FinalSlides).
/// "@modfolder(NecromancerTome):..." is the exact mod-relative path syntax
/// XUiV_Video.startVideo resolves via ModManager.TryPatchModPathString (decompiled to
/// confirm - looks for "@modfolder(&lt;mod name&gt;):" and substitutes the mod's real
/// install path; "NecromancerTome" here is this mod's own ModInfo.xml Name, not its
/// DisplayName).</summary>
public const string BlackPortalVideoPath = "@modfolder(NecromancerTome):Video/BlackPortal.webm";
/// Второй половины прежнего сценария - паузы и полноэкранного видео - здесь больше нет:
/// после "Да" управление уходит в FinalSlides (см. ActivateBlackPortal ниже), пауза живёт
/// там, а видео из финала убрано 2026-09-09. Разбор GameManager.Instance.Pause и
/// XUiC_VideoPlayer.PlayVideo переехал в NoteFlashbackPatch.cs - единственное место в моде,
/// где обе эти ванильные API ещё вызываются.</summary>
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
{
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
@@ -220,55 +198,18 @@ namespace NecromancerTome
}
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом
/// (Video/BlackPortal.webm), теперь запускается финальная сцена из шести слайдов с текстом
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора
/// концовки на последнем слайде.
///
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
/// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается.</summary>
/// теперь живёт внутри FinalSlides. Видео из концовки убрано целиком 2026-09-09 -
/// ни здесь, ни в FinalSlides его больше нет.</summary>
public static void ActivateBlackPortal(EntityPlayerLocal player)
{
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
FinalSlides.Begin(player);
}
/// <summary>Прежняя концовка "сразу видео, потом главное меню". Больше ниоткуда не
/// вызывается (см. ActivateBlackPortal выше) - оставлена целиком, потому что весь разбор
/// Pause/PlayVideo/Disconnect в её комментариях остаётся актуальным и на неё ссылается
/// FinalSlides. Удалять при следующей уборке, если так и не понадобится.</summary>
public static void PlayBlackPortalVideoLegacy(EntityPlayerLocal player)
{
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", pausing + playing video");
GameManager.Instance.Pause(true);
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
VideoData videoData = new VideoData { url = BlackPortalVideoPath };
XUiC_VideoPlayer.PlayVideo(playerUI.xui, videoData, true, delegate(bool skipped)
{
// EXIT TO MAIN MENU after the video, user request 2026-08-30 ("После видео нужно
// выходить из игры в главное меню") - fires whether the video played to the end
// or was skipped (Cancel key / a bad file), same as any other "the video is over"
// outcome. GameManager.Instance.Disconnect() is not a guess - it's the EXACT same
// call the real in-game ESC menu's own "Exit to Main Menu" button uses
// (decompiled XUiC_InGameMenuWindow.exitGame/BtnExit_OnPressed to confirm: it's a
// thin wrapper straight to this method). Handles everything a clean exit needs by
// itself - closes modal windows, un-pauses (calls Pause(false) internally, so no
// separate unpause call needed here), saves/shuts down the local server, and
// returns to XUiC_MainMenu - not reinventing any of that by hand. Replaces the
// earlier "thrownStonePortalBlackNotBound" tooltip placeholder entirely: with a
// real exit-to-menu ending, staying in-game and showing a tooltip no longer makes
// sense (BACKLOG.md item 6's "destination not decided" placeholder is now this
// exit itself, not a tooltip).
Debug.Log("[NecromancerTome] PortalStonePatch: black portal video finished (skipped=" + skipped + "), exiting to main menu");
GameManager.Instance.Disconnect();
});
}
/// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
+38 -17
View File
@@ -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&lt;int, Bag&gt; 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;
/// <summary>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.</summary>
/// <summary>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.</summary>
public static readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
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)
+366
View File
@@ -0,0 +1,366 @@
using System;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text;
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// 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.
/// </summary>
public static class SpatialVaultPersistence
{
/// <summary>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.</summary>
public const byte PayloadVersion = 1;
/// <summary>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.</summary>
public class VaultSlot
{
public Bag Bag;
}
/// <summary>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.</summary>
public static readonly ConditionalWeakTable<PlayerDataFile, VaultSlot> AttachedVaults =
new ConditionalWeakTable<PlayerDataFile, VaultSlot>();
/// <summary>
/// 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.
/// </summary>
public static Bag LastLoadedVault;
/// <summary>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.</summary>
public static string lastSaveLogged;
/// <summary>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.</summary>
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();
}
}
/// <summary>Null when the payload holds no vault or is a version we do not know.</summary>
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 });
}
/// <summary>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.</summary>
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();
}
}
/// <summary>Live player -> save file: take the vault along.</summary>
[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);
}
}
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
}
/// <summary>Appends the vault after everything vanilla wrote - to disk via Save, or to the
/// wire via WriteNetwork.</summary>
[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);
}
}
}
/// <summary>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".</summary>
[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);
}
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
<DisplayName value="Necromancer's Tome" />
<Description value="A dark necromancy progression for 7 Days to Die 3.2: a kill-count-driven skill tree with cursed weapons, charm/deviation magic, summonable undead pets, base-defence wards, and a story-ending Black Portal ritual. Fully localized into 13 languages. Single-player; requires EAC off." />
<Author value="Alex Cube" />
<Version value="1.0.0" />
<Version value="1.0.1" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+5 -2
View File
@@ -1,8 +1,9 @@
# Книга некроманта / Necromancer's Tome (NecromancerTome)
**Версия 1.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
**Версия 1.0.1** — для 7 Days to Die 3.2. Автор: Alex Cube.
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
- Репозиторий: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
- YouTube-канал автора: https://www.youtube.com/@alexcube
@@ -120,7 +121,9 @@
## Статус
Версия 1.0 — весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
Версия 1.0.1исправление по первому баг-репорту с Nexus: содержимое Пространственного
браслета больше не пропадает после выхода из игры (хранилище теперь сохраняется в файле
игрока, рядом с рюкзаком). Весь заявленный контент реализован и проходит тесты в игре. Из запланированного не
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -2
View File
@@ -64,7 +64,8 @@
<h2>Ссылки</h2>
<ul>
<li>Исходники и загрузка: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
<li>Скачать на Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
<li>Исходники: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
<li>YouTube-канал автора: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
</ul>
@@ -138,6 +139,7 @@ Built for single-player. In multiplayer the vanilla pause does not apply, so the
<h3>Links</h3>
<ul>
<li>Source and download: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
<li>Download on Nexus Mods: <a href="https://www.nexusmods.com/7daystodie/mods/12547" target="_blank" rel="noreferrer noopener">nexusmods.com/7daystodie/mods/12547</a></li>
<li>Source: <a href="https://git.08h.ru/alex/necromants-tome-7d2d-3-2" target="_blank" rel="noreferrer noopener">git.08h.ru/alex/necromants-tome-7d2d-3-2</a></li>
<li>The author's YouTube channel: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
</ul>
+99
View File
@@ -0,0 +1,99 @@
using System.IO;
namespace NecromancerTome
{
/// <summary>
/// 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.
/// </summary>
public static class SpatialVaultBlobIO
{
/// <summary>ASCII "NECROVLT" as one int64 - distinctive enough that stray bytes will not
/// be mistaken for our block.</summary>
public const long Magic = 0x4E4543524F564C54L;
/// <summary>Magic (8) + length (4).</summary>
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);
}
/// <summary>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.</summary>
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;
}
}
}
}