28 Commits
Author SHA1 Message Date
AlexCubeandClaude Opus 5 fbd58ad018 README: пометка о непроверенном приведена к текущему состоянию
Врезка называла два пункта, а их стало больше. Теперь перечислено и то, что
подтверждено в игре (вкладка испытаний, два первых испытания, счёт убийств), и
то, что нет: Кровавый камень, новый порог Кровавой сферы, награды испытаний и два
новых испытания на Камень духов и Синий портальный камень.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:44:54 +03:00
AlexCubeandClaude Opus 5 e71cd69d1e Названия испытаний: в стиле, заданном пользователем
Мои были описательные ("Первый подданный", "Короткая дорога домой") - верные по
смыслу и скучные. Пользователь предложил свои:

    Камень духов             "Не бейте меня"          подчинённый зомби бьётся за
                                                      игрока, по игроку не бьют
    Синий портальный камень  "Сам себе G.L.A.D.O.S."  отсылка к Portal
    Нож некроманта           "Основной инструмент"    каламбур на "Основной инстинкт"

"Безумный донор" не тронут - он и был авторский, и уже в этом стиле.

КАК РЕШАЛСЯ ПЕРЕВОД, потому что шутки переводятся плохо:

"Не бейте меня" - буквально на все 13, смысл не зависит от культуры.

"Сам себе G.L.A.D.O.S." - имя ИИ сохранено латиницей во всех языках (имя
собственное, в локализациях Portal оно тоже латиницей), переводится только
обёртка.

"Основной инструмент" - каламбур на названии фильма, живёт только там, где
название созвучно. Сохранён: en Basic Instinct -> Basic Instrument, es Instinto
basico -> Instrumento basico, pl Nagi instynkt -> Nagi instrument, ru Основной
инстинкт -> Основной инструмент. Где фильм известен под английским названием или
созвучия нет (de/fr/it/ja/ko/tr/zh) - взят прямой смысл "главный инструмент".
Мёртвую шутку туда не подставляли: каламбур, который не звучит, читается как
ошибка переводчика, а не как юмор.

Описания не переписаны, остались в серьёзно-атмосферном тоне. Контраст выглядит
скорее намеренным; свести тон - отдельная правка на 4 ключа x 13 языков.

Ключей по-прежнему 149, менялись значения, не состав.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:43:19 +03:00
AlexCubeandClaude Opus 5 d8f06cd53a Награды испытаний по реальной цене + Камень духов и портальный камень
Пользователь: "за кровь 100 XP маловато". Награды не задавались вовсе, поэтому
все испытания падали на challenge_reward_default (100 XP, gameevents.xml:602).
Своих событий заводить не нужно - готовы 500/1000/2000/2500/5000/10000.

ИСПРАВЛЕНА МОЯ ЖЕ ОЦЕНКА. Я предложил оставить ножу 100, обосновав это тем, что
он "один крафт, без риска", - и сказал это ДО чтения рецепта. В нож входит
1 Кровь некроманта, а её крафт снимает 90 процентов ТЕКУЩЕГО здоровья. Нож стоит
захода на порог смерти, аналогия с craftStoneAxe была неверной. Поставлено 500.

Итоговая лестница, каждое число обосновано ценой:

    Камень духов             1 камень + 20 волокон                     100
    Нож некроманта           5 костей + 10 волокон + 1 кровь           500
    Синий портальный камень  150 праха зомби + 3 минералки             2000
    Безумный донор           15 крафтов крови = 15 x 90% ХП            2500

Портальный камень: 2000 при одном крафте, потому что в рецепте 150 праха зомби.
Прах падает с помеченной Жертвы по 1-4 с вероятностью 0.4 (loot.xml), в среднем
около единицы за убийство - то есть порядка 150 ритуальных убийств ножом.
Ванильный craftForgedIron (100 крафтов) платит 2000 за такой же грайнд без риска.

Донор: 2500, выше портального камня, потому что оплачивает не грайнд, а
смертельный риск. Выше не пошли - 5000 ваниль держит за химстанцией и тирами
квестов.

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

ДВА НОВЫХ ИСПЫТАНИЯ, названия под механику:
  "Первый подданный" - Камень духов подчиняет зомби, и тот бьётся за игрока; это
  первое существо, которое слушается некроманта. Иконка specters_grace совпадает
  и с названием предмета, и со смыслом.
  "Короткая дорога домой" - камень телепортирует к спальному мешку и не
  расходуется. Иконка map_house.

Локализация: 6 ключей x 13 языков, ключей стало 149. Счётчик в README и
SITE_DESCRIPTION обновлён. Текст наград заводить не пришлось - ключи
challenge_reward_*xp ванильные.

НЕ ПРОВЕРЕНО В ИГРЕ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 00:41:55 +03:00
AlexCubeandClaude Opus 5 6301a62a54 Испытание "Безумный донор": вкладка укомплектована
В скелет сознательно клали одно испытание, чтобы сперва убедиться, что вкладка
появляется. Убедились в игре - добавлено второе, оба заказанных на месте.

    <objective type="Craft" item="resourceNecromancerBlood" count="15"/>

Craft, а не Gather: кровь не добывается из мира, она крафтится. count у Craft
работает - проверено заранее, потому что в самых наглядных ванильных примерах
(craftStoneAxe, craftClothes) счётчика нет и легко решить, что его не бывает; на
деле 18 ванильных Craft-целей его несут, вплоть до resourceForgedIron count=100.

Иконка ui_game_symbol_siphoning_strikes ("высасывающие удары") - найдена при
разборе всех 359 доступных символов. Под механику "плати собственной кровью"
попадает точнее любого медицинского символа.

Имя оправдано механикой, а не просто звучит: каждый крафт снимает 90 процентов
ТЕКУЩЕГО здоровья (NecromancerBloodPatch.cs) и требует нож в инвентаре.
Пятнадцать раз подряд - пятнадцать раз до полусмерти.

Зависимости от первого испытания нет и не будет (указание "разрозненно"), хотя
фактически кровь без ножа не скрафтить: игрок сам упрётся в нож, если начнёт с
крови, и это честнее замка.

Локализация: 3 ключа x 13 языков, ключей стало 143. Счётчик в README (RU+EN) и
SITE_DESCRIPTION.html (RU+EN) обновлён той же правкой. Шапка challenges.xml
переписана - она описывала состояние "одно испытание из двух".

НЕ ПРОВЕРЕНО В ИГРЕ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:47:51 +03:00
AlexCubeandClaude Opus 5 156e046cc2 Череп вместо зомби на скилле, вкладке и двух баффах
Вкладка испытаний подтверждена в игре ("вкладка видна, нож сработал"), лог
чистый - ERR за весь запуск ноль, challenges загрузились за 0.08. Заодно
подтвердилось, что осознанный риск скелета сыграл: у группы нет reward_event и
reward_text_key, и игра не сказала ни слова - умолчания корня challenges на
группы распространяются.

По указанию пользователя ui_game_symbol_zombie заменён на ui_game_symbol_skull
во всех четырёх живых местах мода:

    progression.xml:90   crafting_skill craftingNecroNecromancy  - сам скилл
    challenges.xml:41    challenge_category NecroNecromancy     - вкладка
    buffs.xml:14         buffNecroZombieKillTrackerDisplay      - счётчик убийств
    buffs.xml:175        buffNecroDarkSense                     - Тёмное чутьё

Причина пользователя: "Значок зомби - протокольная рожа, как у уголовника. Череп
будет более некромантским."

Вкладка и скилл меняются ТОЛЬКО ПАРОЙ - они обязаны читаться как одно целое. Это
записано комментарием в challenges.xml, чтобы позже их не развели по одному.

СОЗНАТЕЛЬНО НЕ ТРОНУТО: комментарий buffs.xml:150 упоминает ui_game_symbol_zombie,
но описывает ВАНИЛЬНЫЙ класс nav_object="zombie" и его собственную иконку на
карте и компасе. Это не наша иконка, текст не устарел.

Чистый XML, пересборка не нужна.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:45:00 +03:00
AlexCubeandClaude Opus 5 90d8fe331b Вкладка испытаний Некромантии: скелет
Новый Config/challenges.xml - мод его раньше не имел. Категория (вкладка),
группа и ОДНО испытание "Первым делом - нож".

Одно, а не два заказанных, намеренно: файл трогается впервые, сначала надо
увидеть в игре, что вкладка вообще появляется. Нож крафтится сразу и без
верстака, то есть проверка занимает минуту. "Безумный донор" добавится одной
записью, когда скелет подтвердится.

РАЗРОЗНЕННО, НЕ ЦЕПОЧКОЙ (по указанию): у группы нет ни link_challenges, ни
hidden_by. Побочная польза - снялось противоречие порядка: Кровь некроманта
нельзя скрафтить без ножа, так что "донор" первым в цепочке был бы невыполним.

ИКОНКИ ТОЛЬКО ИЗ СИМВОЛЬНОГО АТЛАСА ИГРЫ. В templates.xml оба места, где они
рисуются (challenge_entry и category_icon), объявлены БЕЗ атрибута atlas, то есть
читают атлас по умолчанию; в ванильном challenges.xml все 215 иконок имеют вид
ui_game_symbol_* без исключений. Свой атлас мода (NecroFinal) не поможет -
обратиться к нему можно только явным atlas= на спрайте, а дописать его в шаблон
нельзя: шаблон один на все испытания, ванильные иконки пропадут.

Из 359 собранных символов нашлись некромантские, череп не понадобился. Вкладка -
ui_game_symbol_zombie (тот же символ, что у скилла в progression.xml, чтобы
вкладка и скилл читались как одно целое), нож - ui_game_symbol_knife. Для
"Безумного донора" отложен ui_game_symbol_siphoning_strikes.

Локализация: 5 ключей x 13 языков, ключей стало 140. Название вкладки взято слово
в слово из craftingNecroNecromancyName. Счётчик в README и SITE_DESCRIPTION
обновлён 135 -> 140.

ОСОЗНАННЫЙ РИСК: у группы нет reward_text_key и reward_event, хотя у всех 20
ванильных они есть. Ставка на умолчания корня challenges (default_reward,
default_reward_text_key). Если не сработает - будет в логе, и это дешевле, чем
заводить событие награды вслепую.

НЕ ПРОВЕРЕНО В ИГРЕ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:29:46 +03:00
AlexCubeandClaude Opus 5 03b4b8f164 README: пометка, что сборка проверена не полностью
По указанию пользователя перед пушем. Текст коммитов про непроверенное видит
только тот, кто читает историю; README видно сразу.

В обеих половинах (RU и EN) раздел "Статус" теперь перечисляет, что подтверждено
в игре, и отдельной врезкой - что нет: Кровавый камень (нужен уровень 2000 и
химстанция) и новый порог Кровавой сферы (переехала с уровня 1 на 20). Указано,
что это ожидаемое место для сюрприза.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:19:03 +03:00
AlexCubeandClaude Opus 5 c832f734d7 Кровавая сфера открывается вместе с браслетом, а не с уровня 1
Сфера вставляется в Пространственный браслет, но открывалась с уровня 1,
тогда как сам браслет - с 20. Девятнадцать уровней её можно было крафтить в
пустоту. По записанному правилу мода "пороги - по нужде, а не по силе" порог
ставится туда, где предмет впервые нужен.

progression.xml: resourceBloodSphere убрана из unlock_entry с unlock_tier="1" и
дописана в строку браслета (unlock_tier="2").
recipes.xml: теги рецепта packMuleCrafting -> learnable,packMuleCrafting,
necroNecromancyLvl20. learnable как у всех гейтованных рецептов мода, чтобы
рецепт не светился в меню до открытия.

Два числа обязаны совпадать и совпадают: RecipeTagUnlocked для
necroNecromancyLvl20 стоит на level="20,5000", а unlock_tier="2" в группе с
unlock_level="1,20,30,60,100,300" - это те же 20. Разойдись они, замок на панели
скилла разошёлся бы с реальной доступностью рецепта.

Чистый XML, пересборка не нужна. Комментарии в обоих файлах переписаны - там
стояло "ЗАМЕЧЕНО, НЕ ИСПРАВЛЕНО".

НЕ ПРОВЕРЕНО В ИГРЕ: на свежем персонаже рецепта быть не должно, после 20
убийств должны открыться браслет и сфера одной строкой.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:17:31 +03:00
AlexCubeandClaude Opus 5 8c5636b523 Версия 1.2.0: заряд браслета и починенный счёт убийств
Проверка в игре прошла, версия поднята с 1.1.0 до 1.2.0, описания доведены до
текущего состояния мода.

ПОЧЕМУ МИНОРНАЯ, А НЕ ПАТЧ. Тег v1.1.0 указывает на 7172681. Заряд браслета
(Кровавая сфера и Кровавый камень), починка счёта убийств, кряк боли при крафте
крови и фикс призрачности торговца появились ПОСЛЕ него и в выпущенную 1.1.0 не
входили. Это новая функциональность, а не исправления к выпущенному.

ЧТО ПОДТВЕРЖДЕНО В ИГРЕ:
  - Кровавая сфера исчезает при обнулении прочности, звук разбивания играет;
  - кряк боли при создании Крови некроманта играет;
  - торговец остаётся призраком (на одном торговце; на трёх будет проверено
    отдельно);
  - зомбопёс засчитывается в скилл - причина 1 (пять зомби-зверей вне
    zombieTemplateMale) закрыта, фильтр по тегу zombie работает;
  - зомби, умерший от кровотечения, засчитывается - причина 2 (DamageSource
    вместо буквального убийцы) закрыта;
  - зомби, сгоревшие от Пирамиды духов, засчитываются. Этого случая не было ни в
    указании, ни в плане - он достался бесплатно, потому что хук стоит на
    EntityPlayer.AddKillXP, то есть следует решению ванили, а не перечисляет
    причины смерти по одной.

ПИТОМЦЫ: ВОПРОС ЗАКРЫТ ОТРИЦАТЕЛЬНО. Зомби под контролем Камня духов, убивая
другого зомби, в скилл не идёт - это и есть случай "убил питомец", и ваниль
владельцу такое убийство не зачисляет. Патч следует её решению, как и написано в
его комментарии. Пункт исходного указания про призванных существ требует
отдельной работы (проставить владельца в DamageSource питомца); пользователь
решил оставить как есть.

ОПИСАНИЯ (README.md, RU и EN):
  - шапка версии в обеих половинах;
  - новый абзац 1.2.0 в "Статусе", 1.1.0 и 1.0.1 сдвинуты в историю. Заодно
    исправлена фактическая ошибка: раздел приписывал сферу и камень версии 1.1.0,
    хотя их там не было;
  - описание скилла теперь говорит, что засчитывается любое убийство, за которое
    игра даёт опыт - ловушка, огонь, кровотечение, зомби-звери наравне с
    человекоподобными. Раньше было просто "счётчик упокоенных зомби", и это
    вводило в заблуждение ровно в тех случаях, что чинились;
  - снята оговорка про непроверенную сферу, вместо неё честная оговорка про
    непроверенный Кровавый камень.

SITE_DESCRIPTION.html и NEXUS_DESCRIPTION.bbcode номера версии не содержат, сферу
и камень получили раньше - правки не потребовалось.

НЕ ПРОВЕРЕН В ИГРЕ Кровавый камень: собран и на месте, но требует уровня
Некромантии 2000 и химстанции. В README это сказано прямым текстом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 22:11:41 +03:00
AlexCubeandClaude Opus 5 229b436420 Счёт убийств Некромантии переехал из XML в код
Чинит баг: скилл не засчитывал часть убийств зомби. Причин было две, и обе
закрываются одним ходом.

ПРИЧИНА 1: ОДИН КЛАСС - НЕ ВСЕ ЗОМБИ. Счёт висел append-ом на zombieTemplateMale.
Человекоподобные покрыты (effect_group у entity_class наследуется через extends, и
все шаблоны зомби сходятся к Male), но пять зомби-ЗВЕРЕЙ наследуют животную ветку
и до него не доходят вовсе:

    animalZombieBear   extends animalBear
    animalZombieBoar   extends animalBoar
    animalZombieDog    extends animalWolf
    animalZombieVulture extends animalTemplateHostile
    animalZombieVultureRadiated extends animalZombieVulture

Убийство зомбопса, зомбомедведя, зомбокабана и зомбоворона не считалось никак.
Вороны попадаются постоянно - это и была бОльшая часть "не всегда засчитывает".

ПРИЧИНА 2: target="other" - БУКВАЛЬНЫЙ УБИЙЦА. Триггер onOtherKilledSelf с
требованием EntityTagCompare tags="player" начислял только за убийство своей
рукой. Робомолот, кровотечение и питомцы требование не проходят, хотя опыт игрок
за них получает: ваниль определяет получателя не по убийце, а по DamageSource - в
EntityAlive.AwardKillXPServer, где для этого есть обращение к BuffClass (DoT) и
отдельный флаг bTrapKillXP (ловушки).

Требование было ОДНО на оба эффекта, поэтому недосчитывался и
necroZombieKillsCVar, а это урон Ножа некроманта (items.xml: Damage = CVar / 10).
Баг тихо занижал ещё и нож.

РЕШЕНИЕ: Postfix на EntityPlayer.AddKillXP. Этот метод вызывается ровно из одного
места во всей сборке - из AwardKillXPServer, то есть уже ПОСЛЕ того, как ваниль
разобрала DamageSource и решила, чей это фраг (проверено сканированием IL).
Мы не повторяем её логику и не угадываем владельца турели или автора
кровотечения - забираем готовый ответ. Наш счёт совпадает с опытом на экране по
построению, включая случаи, о которых мы не подумали.

ФИЛЬТР ПО ТЕГУ zombie, А НЕ ПО КЛАССУ. Проверено по данным: zombieBiker,
zombieArlene, zombieBoe, zombieSpider несут "entity,zombie,...", зомби-звери -
"entity,animal,zombie,zombieAnimal,...". Тег есть у всех. Работает это благодаря
тому, что Tags у entity_class НЕ наследуется через extends: каждый реально
спавнящийся зомби выписывает теги сам, а безтеговые шаблоны не спавнятся. Тег
переживёт и новых зомби из патчей игры, и чужие моды.

Повышение уровня воспроизводит MinEventActionAddProgressionLevel.Execute шаг в
шаг по его IL: GetProgressionValue, Level+1, кламп по MaxLevel, для крафтового
скилла AddCraftingSkillNotification и HandleCheckCrafting, затем
bProgressionStatsChanged и bPlayerStatsChanged под !isEntityRemote.
HandleCheckCrafting легко выбросить и дорого потерять - без него рецепты рискуют
не заметить, что открылись. Уведомление с _bAddOnlyIfNotExisting=true, чтобы в
орду не всплывал тост на каждый труп.

Config/entityclasses.xml: append снят ЦЕЛИКОМ, на его месте комментарий, почему
возвращать нельзя - XML-триггер рядом с патчем засчитает убийство своей рукой
ДВАЖДЫ. Это единственная ловушка переезда.

Сборка: 0 ошибок (4 прежних MSB3277). Проверено рефлексией по собранной DLL:
атрибут нацелен верно, перегрузка AddKillXP ровно одна, имя параметра killedEntity
совпадает с ванильным (Harmony инжектит по имени - опечатка дала бы молчаливо
неработающий патч), PatchAll подхватывает файл сам.

НЕ ПРОВЕРЕНО В ИГРЕ. Отдельно: питомцы НЕ гарантированы - патч следует решению
ванили, а не принимает его. Если ваниль не зачисляет владельцу убийство
питомцем, не зачислит и он; это отдельная работа, а не ошибка здесь.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 21:21:11 +03:00
AlexCubeandClaude Opus 5 e890999391 Кряк боли при создании Крови некроманта
Крафт крови и так снимает 90% текущего ХП, но делал это молча. Теперь на том же
месте играет штатный звук боли игрока - тот, что слышно при накалывании на кол
или на колючую проволоку.

ИМЯ КЛИПА СПРАШИВАЕТСЯ У СУЩНОСТИ, А НЕ ВПИСАНО СТРОКОЙ. Звук боли зависит от
пола персонажа (Data/Config/entityclasses.xml):

    playerMale     SoundHurt="player1painlg"   SoundHurtSmall="player1painsm"
    playerFemale   SoundHurt="player2painlg"   SoundHurtSmall="player2painsm"

playerFemale extends playerMale и переопределяет обе строки, так что
захардкоженный "player1painlg" выдал бы женскому персонажу мужской кряк. Вместо
этого зовутся GetSoundHurt() и GetSoundHurtSmall() - публичные методы
EntityAlive, каждый из которых целиком есть чтение поля (проверено по IL:
"ldarg.0; ldfld soundHurt|soundHurtSmall; ret", токены разрешены через
Module.ResolveField).

ПОРОГ ВЗЯТ У САМОЙ ИГРЫ. EntityPlayer переопределяет
GetSoundHurt(DamageSource, int) - тот самый метод, через который проходят колья
и колючая проволока. Его IL разобран целиком, и правило такое: урон СТРОГО
больше 15 -> большой кряк (soundHurt), 15 и меньше -> малый (soundHurtSmall),
плюс фолбэк на большой, если малый не объявлен. Воспроизведено один в один,
включая строгое "больше" и фолбэк; константа названа PainSoundBigDamage и несёт
этот разбор в комментарии.

Сам GetSoundHurt(DamageSource, int) не вызывается: ему нужен DamageSource, а
здесь урона от источника нет - ХП снимается через AddHealth, осознанно (см.
комментарий на месте вызова). Правило выбора - три строки, поэтому оно повторено,
а не подделано синтетическим DamageSource.

PLAYONESHOT ВЫЗВАН РОВНО КАК В ВАНИЛИ. Единственный вызывающий -
EntityAlive.OnUpdateEntity, IL 348: там PlayOneShot получает
sound_in_head:false, serverSignalOnly:false, isUnique:false, _animEvent:null,
volumeScale:1f. Значения по умолчанию у Entity.PlayOneShot - те же самые
(сверено через RawDefaultValue), так что вызов одним аргументом поведенчески
идентичен ванильному: кряк идёт ОТ персонажа, а не "в голове". Оттуда же взят
null-guard - ваниль пропускает вызов при пустом имени клипа, а не отдаёт его
PlayOneShot.

Вызов стоит ПОСЛЕ AddHealth(-amount), а не до: крафт, который свалился бы выше,
не должен издавать звук, которому игрок не найдёт объяснения. При цене 90% это
практически всегда большой кряк - малый включится только на остатке около 17 ХП,
там же, где его сделала бы малым ваниль.

ЗАОДНО: устаревшая шапка NecromancerBloodPatch.cs. Комментарий утверждал, что
кровь живёт в item_modifiers.xml, "not in items.xml, since 2026-09-15". Откат
15.09 это отменил, но тогда правились только комментарии в items.xml, а .cs
пропустили. Переписано: кровь в items.xml, и рядом коротко, почему её оттуда
нельзя двигать.

Сборка: 0 ошибок (4 прежних MSB3277 про версии System.Runtime, к правке
отношения не имеют). Скомпилированный PlayPainSound проверен рефлексией по
готовой DLL - вызовы идут в ожидаемом порядке.

НЕ ПРОВЕРЕНО В ИГРЕ. Проверять обоими полами персонажа - ради этого имя клипа и
спрашивается у сущности.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:35:01 +03:00
AlexCubeandClaude Opus 5 5a512260cc Кровавый камень: бесконечный заряд браслета
Эндгейм-версия расходника Пространственного браслета: то же место в слоте, что у
Кровавой сферы, но не тратится вовсе. Кода менять не пришлось - только конфиги.

ЗАОДНО ЭТО ПОЧИНКА УЖЕ ОТГРУЖЕННОГО ТЕКСТА. Сообщение о пустом слоте
(braceletSpatialVaultNoMod) и описание браслета обещают игроку "кровавую сферу
ИЛИ кровавый камень" на всех 13 языках с 15.09. Пока камня не было, оба текста
отправляли за предметом, которого в игре не существует.

СОВМЕСТИМОСТЬ СЕЙВОВ - ПЕРВОЕ, ЧТО ПРОВЕРЕНО. resourceBloodStone - НОВОЕ имя, в
существующих сейвах его нет, значит нет и ни одного стака, который читался бы по
другой байтовой раскладке. Это ровно то условие, нарушение которого в e362c62
убило персонажа: ItemValue.Read/Write гейтят блок модификаций одним условием
!(itemClass is ItemClassModifier), и класс предмета определяет раскладку каждого
стака в сейве. Камень заведён новым предметом, а не переводом существующего -
тем же решением, что и Кровавая сфера. Добавление нового <item_modifier>
раскладку уже записанных стаков не меняет: айди новых предметов дописываются в
name->id мэппинг сейва, байты старых стаков остаются как были.

БЕСКОНЕЧНАЯ ПРОЧНОСТЬ СДЕЛАНА ОТСУТСТВИЕМ СТРОК, А НЕ БОЛЬШИМ ЧИСЛОМ. У камня
сознательно нет ни ShowQuality, ни DegradationBreaksAfter, ни effect_group с
DegradationMax - сравнить со сферой, у которой все три есть. SpendCharge
(SpatialVaultPickupPatch.cs) списывает прочность только у модификации, чьё имя
равно ChargeItemName ("resourceBloodSphere"); камень под условие не попадает,
цикл делает continue, его UseTimes не трогает никто. Большой DegradationMax был
бы хуже: неподвижная полоска прочности бесполезна, а при MaxUseTimes == 0 она
рисуется ПОЛНОЙ, то есть забытая пассивка выглядела бы как "всё работает".

Три места в коде сверены на то, что камень пройдёт весь путь:

  1. Гейт пустого слота в Begin - это !_bracelet.HasMods(), то есть "в слоте
     что-нибудь есть", а НЕ "есть сфера по имени". Камень гейт проходит. Это был
     главный риск правки: искал бы гейт сферу по имени, камень не работал бы.
  2. SpendCharge вызывается ПОСЛЕ SetBlockRPC(..., Air) и bag.AddItem, а его
     результат только логируется - ветки "не заплатил -> откажи" нет. Блок с
     камнем уходит в хранилище, в логе "took ... into the vault for 0 of charge".
  3. Ветка max <= 0 в SpendCharge камня не касается: до неё доходят только
     предметы, прошедшие проверку имени.

ФАЙЛЫ

Config/item_modifiers.xml - новый item_modifier resourceBloodStone,
installable_tags="necroBracelet", свой modifier_tags="necroBraceletStone".
Extends="modGeneralMaster" param1="CustomIcon" - НЕ resourceRockSmall: с ним
приехали бы Action0 ThrowAway и ThrowableDecoy, и камень можно было бы метать
как отвлекающий (на сфере эта ошибка уже была и была исправлена). Вид в руке -
HoldType 40 плюс все три меша rock_smallPrefab плюс TintColor "220, 30, 45", как
у сферы и трёх камней мода. CustomIcon="BloodStone", без CustomIconTint:
иконка своя, рисованная, лежит в обеих папках атласа с 15.09.

Config/recipes.xml - рецепт на химстанции, craft_area="chemistryStation",
tags="learnable,chemStationCrafting,necroNecromancyApprentice": 50 праха зомби,
20 костей, 4 обычной крови, 1 кровь некроманта. Все четыре имени проверены по
файлам. Цена выше, чем читается по списку: крафт самой крови некроманта снимает
90% текущего ХП.

Config/progression.xml - камень добавлен в unlock_entry третьего display_entry
(unlock_level="2000", unlock_tier="1"). Это только строка в панели скилла;
открытие рецепта делает тег. 2000 здесь и 2000 у RecipeTagUnlocked обязаны
совпадать, иначе замок на панели разойдётся с доступностью рецепта.

Config/Localization.csv - два ключа, resourceBloodStone и
resourceBloodStoneDesc, 13 языков; ключей стало 135. Названия камня взяты из уже
переведённого braceletSpatialVaultNoMod, так что сообщение об отказе и имя в
инвентаре совпадают слово в слово. braceletSpatialVaultDesc дополнено на всех 13
языках: камень не тратится. Файл переписан через csv-модуль, round-trip без
правок даёт побайтово тот же файл - кавычки и CRLF остальных строк не поехали.

ОПИСАНИЯ. README.md, SITE_DESCRIPTION.html и NEXUS_DESCRIPTION.bbcode доведены
до текущего состояния мода: сфера и камень добавлены в таблицы тиров и в список
арсенала, у браслета сказано, что забор блоков питается зарядом в слоте,
счётчик ключей 130 -> 135. Заодно снято утверждение "весь контент проходит тесты
в игре" - оно перестало быть верным: расход сферы в игре подтверждён, камень в
игру ещё не грузился.

НЕ ПРОВЕРЕНО В ИГРЕ: камень целиком. Всё выше - сверка по файлам и по
декомпиляции. Сценарий проверки в BACKLOG.md; главное, на что смотреть, -
отсутствие полоски прочности у камня в слоте и "for 0 of charge" в логе.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:24:22 +03:00
AlexCubeandClaude Opus 5 896501dcd8 Откат крови и Кровавая сфера вместо неё
Чинит поломку сейвов, внесённую предыдущим коммитом e362c62, и заменяет её
механику на безопасную.

ЧТО БЫЛО СЛОМАНО. e362c62 перенёс resourceNecromancerBlood из items.xml в
item_modifiers.xml, чтобы кровь вставлялась в Пространственный браслет. Класс
предмета определяет БАЙТОВУЮ РАСКЛАДКУ каждого его стака в сейве:

    ItemValue.Read  строка 1094:  if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
    ItemValue.Write строка 1228:  if (!(ItemClass is ItemClassModifier))

Обычный предмет пишет байт числа модификаций и байт числа косметических слотов;
ItemClassModifier не пишет ни того, ни другого. Сейв, записанный до переноса,
после переноса читается со сдвигом - поток съезжает на первом же стаке крови, и
PlayerDataFile.Load падает. Бэкап .ttp.bak умирает вместе с основным файлом: он
того же формата. В тестовом мире персонаж потерян вместе с бэкапом, игра
откатилась на Respawning: NewGame.

В коммите e362c62 написано "сейв цел" со ссылкой на assignIdsFromMapping. Айди
действительно берутся из сохранённого name->id мэппинга - но ломается не айди, а
раскладка байтов, и к мэппингу это отношения не имеет. Была проверена не та вещь.

ОТКАТ. Кровь вернулась в items.xml обычным <item>. Набор её свойств сверен с
7172681 и совпадает посимвольно: убраны и Stacknumber=1, и прочность
(ShowQuality + DegradationBreaksAfter + effect_group DegradationMax). Обе правки
были безвредны для сейва, но существовали ради отменённой механики - по указанию
пользователя откат доведён до "как было", а не до "как было плюс безвредное".
Стак снова 15, наследуется от medicalBloodBag.

В комментарии у предмета оставлено предупреждение с номерами строк Read/Write -
единственное, что вынесено из аварии, и единственное, ради чего стоит читать этот
комментарий целиком.

КРОВАВАЯ СФЕРА. Расходник браслета теперь отдельный предмет, и это ровно то, что
делает правку безопасной: resourceBloodSphere - НОВОЕ имя, в старых сейвах его
нет, значит нет и ни одного стака, который читался бы по другой раскладке. Общее
правило, выведенное из аварии: предмет, который уже мог попасть в чужой
инвентарь, нельзя переводить между ItemClass и ItemClassModifier ни в какую
сторону - нужна модификация, заводи новый предмет.

Продиктовано: доступна на первом грейде, без станка, 1 кровь некроманта + 5 праха
зомби дают две сферы, прочность 500. В руке - камень с алым тинтом.

  - item_modifiers.xml: installable_tags="necroBracelet", свой modifier_tags,
    type="attachment", DegradationMax 500 в tiered="false" группе.
    Extends=modGeneralMaster, а НЕ resourceRockSmall: вместе с камнем приезжали бы
    Action0 ThrowAway и ThrowableDecoy, то есть сферу можно было бы метать.
    Меш берётся строкой Meshfile, наследовать ради него весь предмет не нужно.
  - Вид в руке: HoldType 40 и все три меша (Meshfile/HandMeshfile/DropMeshfile) на
    rock_smallPrefab плюс TintColor "220, 30, 45". Путь проверенный - на этом же
    меше с таким же тинтом в моде живут Камень духов и оба портальных камня.
  - recipes.xml: count=2, без craft_area и без тега разблокировки (группа 1
    открыта с уровня 1, рецепт без тега доступен всегда - как у Камня духов).
  - progression.xml: сфера в unlock_entry группы 1, unlock_tier="1".
  - SpatialVaultPickupPatch: SpendBlood -> SpendCharge, имя из новой константы
    ChargeItemName. Гейт пустого слота в Begin снова включён - после отката он был
    временно закомментирован, потому что вставлять было нечего.

ИКОНКИ. Свои, от пользователя: BloodSphere.png и BloodStone.png, 160x160 RGBA, в
ItemIconAtlas и ItemIconAtlasGreyscale. Серая копия обязательна - без неё у
заблокированной записи в скиллах не будет картинки вообще. Способ её получения
подобран сверкой с существующими файлами и совпал ПОБИТОВО: convert('L') на RGB
без альфы, альфа приклеивается обратно отдельным каналом. Записано в BACKLOG.md,
раньше это нигде не было зафиксировано. Иконка камня положена заранее - самого
предмета ещё нет, он запланирован.

ПРОВЕРЕНО, ЧТО НИЧЕГО БОЛЬШЕ НЕ ЕДЕТ. Сверка с 7172681: набор <item> в items.xml
не изменился, в item_modifiers.xml единственное добавление - resourceBloodSphere,
и ни у одного существующего предмета не менялись Tags, ShowQuality и Stacknumber.
То есть ни один предмет не сменил класс и не сменил раскладку.

В игре: мир грузится без ошибок, сфера крафтится и тратится - в логе
"sphere in slot 0 now 12/500 used" за 12-секундный канал.

---

Revert the blood, and a Blood Sphere in its place

Fixes the save corruption introduced by e362c62 and replaces the mechanic behind
it with a safe one.

WHAT WAS BROKEN. e362c62 moved resourceNecromancerBlood from items.xml into
item_modifiers.xml so it could be installed in the Spatial Bracelet. An item's
CLASS decides the BYTE LAYOUT of every stack of it in a save:

    ItemValue.Read  line 1094:  if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
    ItemValue.Write line 1228:  if (!(ItemClass is ItemClassModifier))

A plain item writes a modifications count and a cosmetic-slot count; an
ItemClassModifier writes neither. A save written before the move reads out of
step after it - the stream slips on the first blood stack and PlayerDataFile.Load
throws. The .ttp.bak backup dies with the main file, being the same format. In the
test world the character was lost along with its backup and the game fell back to
Respawning: NewGame.

e362c62's message claimed "saves are safe", citing assignIdsFromMapping. Item ids
really do come from the stored name->id mapping - but what breaks is not the id,
it is the byte layout, and the mapping has nothing to do with it. The wrong thing
was verified.

THE REVERT. The blood is a plain <item> in items.xml again. Its property set was
diffed against 7172681 and matches character for character: both Stacknumber=1 and
the durability (ShowQuality + DegradationBreaksAfter + the DegradationMax
effect_group) are gone. Both were harmless to the save format, but both existed
only to serve the cancelled mechanic - on the user's instruction the revert goes
back to "as it was", not "as it was plus whatever I judged harmless". The stack is
15 again, inherited from medicalBloodBag.

A warning carrying the Read/Write line numbers stays in the item's comment - the
one thing worth keeping out of this accident.

THE BLOOD SPHERE. The bracelet's charge is its own item now, and that is precisely
what makes this safe: resourceBloodSphere is a NEW name, absent from every
existing save, so no stack of it can be read under the wrong layout. The general
rule the accident produced: an item that may already sit in someone's inventory
must never be moved between ItemClass and ItemClassModifier in either direction -
if a modifier is wanted, make a new item.

Dictated: available at the first grade, no workstation, 1 Necromancer's Blood +
5 zombie ash makes two spheres, durability 500. Held, it is a stone with a
scarlet tint.

  - item_modifiers.xml: installable_tags="necroBracelet", its own modifier_tags,
    type="attachment", DegradationMax 500 in a tiered="false" group.
    Extends=modGeneralMaster, NOT resourceRockSmall: the rock would have brought
    Action0 ThrowAway and ThrowableDecoy with it, making the sphere throwable.
    The mesh comes from the Meshfile line; inheriting a whole item for it is not
    needed.
  - Held look: HoldType 40 and all three meshes (Meshfile/HandMeshfile/
    DropMeshfile) on rock_smallPrefab, plus TintColor "220, 30, 45". A proven
    path - the Spirit Stone and both portal stones already live on that mesh with
    that same kind of tint.
  - recipes.xml: count=2, no craft_area and no unlock tag (group 1 opens at level
    1, and a recipe with no tag is simply always available, as with the Spirit
    Stone).
  - progression.xml: the sphere joins group 1's unlock_entry at unlock_tier="1".
  - SpatialVaultPickupPatch: SpendBlood -> SpendCharge, the name coming from a new
    ChargeItemName constant. The empty-slot gate in Begin is switched back on - it
    was commented out during the revert because nothing could be installed.

ICONS. The user's own art: BloodSphere.png and BloodStone.png, 160x160 RGBA, in
both ItemIconAtlas and ItemIconAtlasGreyscale. The greyscale copy is mandatory -
without it a locked skill entry has no picture at all. How those copies are made
was worked out by diffing against the existing files and matched BIT FOR BIT:
convert('L') over RGB without the alpha, with the alpha merged back as its own
channel. Written up in BACKLOG.md; it had never been recorded anywhere. The
stone's icon is filed ahead of the item, which is still only planned.

VERIFIED THAT NOTHING ELSE SHIFTS. Diffed against 7172681: the set of <item>
entries in items.xml is unchanged, the only addition to item_modifiers.xml is
resourceBloodSphere, and no existing item had its Tags, ShowQuality or Stacknumber
changed. No item changed class, and no item changed layout.

In game: the world loads clean, and the sphere crafts and drains - the log shows
"sphere in slot 0 now 12/500 used" for a 12-second channel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 23:50:37 +03:00
AlexCubeandClaude Opus 5 e362c627e7 Кровь некроманта - топливо Пространственного браслета
Шесть указаний одного захода, которые сложились в одну механику: поглощение блока
больше не бесплатно. Браслет требует модификацию в слоте, кровь ею стала, и она
на это тратится.

СТАК ПО ОДНОЙ БАНКЕ. Наследуемый medicalBloodBag даёт Stacknumber 15, и это надо
перебивать явно - Extends копирует свойство целиком, а не "если не задано иначе".
Дорого и так задумано: Чёрный портал просит десять банок, то есть десять ячеек.

ПРОЧНОСТЬ 1000. Свойства с именем вроде Durability в игре нет; ручек две, и обе
обязательны. Число - пассивный эффект DegradationMax
(ItemValue.MaxUseTimesBase -> EffectManager.GetValue), полоска - отдельное
свойство ShowQuality (XUiC_ItemStack.ShowDurability -> ItemClass.ShowQualityBar).
Без первой прочность равна нулю, а полоска при MaxUseTimes == 0 рисуется ПОЛНОЙ,
то есть забытый эффект выглядит как "всё работает". tiered="false" обязателен:
ItemClass.HasQuality читается как Effects.IsOwnerTiered(), и тированная группа
превратила бы банку в предмет с качеством, с тирами и рамкой.

КРОВЬ ПЕРЕЕХАЛА В item_modifiers.xml. Это не настройка, а смена класса предмета:
XUiC_ItemPartStack.CanSwap открывается строкой
`if (!(stack.itemValue.ItemClass is ItemClassModifier ...)) return false;` - слот
модификации не смотрит ни на теги, ни на свойства, пока предмет не
ItemClassModifier, а этот класс создаётся только из <item_modifier>. Свойства
вида CanBeInstalled не существует; остаться ресурсом в items.xml и вставляться в
браслет физически нельзя. На старом месте оставлен комментарий-указатель.

Что при этом проверено, а не понадеялось: рецепты резолвятся (ItemClassModifier
наследует ItemClass, имена лежат в общем ItemClass.nameToItem, Recipe ищет через
GetItemClass по тому же словарю); сейв цел (ItemClass.assignIdsFromMapping берёт
айди из сохранённого name->id мэппинга, перестановка в конфигах предмет не
подменит); Harmony-патч и ключи локализации ходят по имени, имя не менялось.

ЛОВУШКА ПЕРЕЕЗДА: у модификации effect_group применяется к предмету, В КОТОРЫЙ её
вставили - прочность 1000 начала бы выдаваться БРАСЛЕТУ. Пассивка гейтована
tags="necroBloodFlask", тег добавлен в Tags флакона: MaxUseTimesBase зовёт
GetValue с ItemTags того предмета, для которого считает.

СЛОТОВ У БРАСЛЕТА 1, было 4. Слот из набора улучшений стал выбором.

ПУСТОЙ СЛОТ ОТКАЗЫВАЕТ. Проверка стоит первой строкой Begin, впереди всех
остальных отказов: прочие про ЦЕЛЬ (нет блока, не тот блок, хранилище полно), эта
про ИНСТРУМЕНТ, и сказать "здесь нет блока", когда пуст браслет, значит отправить
игрока искать не там. Тест - ItemValue.HasMods(), игровой собственный: обходит
только Modifications, пропуская null и IsEmpty, и не считает CosmeticMods, иначе
краска читалась бы как "браслет заряжен". Звук отказа достался бесплатно -
Deny() в этом файле уже играет ванильный ui_denied.

РАСХОД. Цена пула считается ОДИН раз, при старте, и едет в PickupJob.ChannelSeconds
вместе с самим браслетом. Не потому, что так короче: ChannelSecondsFor меряет луч
игрока, а за десять секунд игрок успевает отвернуться - второй вызов насчитал бы
цену за другой блок, а не за тот, который забрали. Браслет хранится экземпляром по
той же причине: моды живут на ItemValue, а колесо прокручивается.

Списывается в SpendBlood, ПОСЛЕ SetBlockRPC и после того, как предмет лёг в
хранилище: все отказы выходят раньше через return, так что кровь за отменённое
поглощение невозможна по построению. Имя предмета берётся из
NecromancerBloodPatch.BloodItemName, а не вторым литералом, чтобы не разъехались.
Мод, который не кровь, не платит ничего и поглощению не мешает - слот задуман под
другие вещи.

КОНЧИЛАСЬ - РАЗБИВАЕТСЯ. Правило именно "прочность 0 или меньше", а не "не хватило
на пул", и разница не косметическая: по второй формулировке флакон, которому
хватило впритык, остался бы в слоте с нулём, HasMods() видел бы "что-то вставлено",
и браслет работал бы бесплатно до конца света. Поэтому зажим по MaxUseTimes убран,
а слот обнуляется через ItemValue.None - это type 0, ровно то, что проверяет
IsEmpty(). Последнее поглощение проходит всегда, флакон его просто не переживает.
Звук - ванильный itembreak, тот же, что играет ItemAction.HandleItemBreak.

Защита: если MaxUseTimes окажется 0 (снесли passive_effect или тег), флакон НЕ
удаляется, а в лог идёт предупреждение с указанием, где чинить. Без этой ветки
ошибка в XML съедала бы игроку предмет на первом же поглощении, и выглядело бы это
багом механики.

Локализация: новый ключ braceletSpatialVaultNoMod в 13 языках, плюс описания
флакона и браслета - механика стала условной и платной, и оба текста без этого
стали бы враньём.

Известное и намеренное: кровавого камня, который обещает сообщение о пустом слоте,
ещё нет - он запланирован, разбор в BACKLOG.md. В игре ничего из этого не
проверено.

---

Necromancer's Blood is the Spatial Bracelet's fuel

Six instructions from one session that add up to one mechanic: pulling a block
into the vault is no longer free. The bracelet needs a mod in its slot, the blood
became that mod, and it is spent doing the work.

ONE JAR PER STACK. The inherited medicalBloodBag sets Stacknumber 15 and it has to
be overridden explicitly - Extends copies a property wholesale, not "unless set".
Expensive on purpose: the Black Portal asks for ten jars, so ten slots.

DURABILITY 1000. There is no property called anything like Durability; there are
two knobs and both are required. The number is a DegradationMax passive effect
(ItemValue.MaxUseTimesBase -> EffectManager.GetValue); the bar is a separate
ShowQuality property (XUiC_ItemStack.ShowDurability -> ItemClass.ShowQualityBar).
Without the first, durability is zero - and the bar at MaxUseTimes == 0 draws
FULL, so a forgotten effect looks exactly like success. tiered="false" is
mandatory: ItemClass.HasQuality is Effects.IsOwnerTiered(), and a tiered group
would have turned the jar into a quality item with tiers and a frame.

THE BLOOD MOVED TO item_modifiers.xml. Not a setting but a change of item class:
XUiC_ItemPartStack.CanSwap opens with
`if (!(stack.itemValue.ItemClass is ItemClassModifier ...)) return false;` - a mod
slot looks at neither tags nor properties until the item is an ItemClassModifier,
and that class is only created from <item_modifier>. No CanBeInstalled property
exists; staying a resource in items.xml and going into the bracelet is impossible.
A pointer comment was left where it used to live.

Checked rather than hoped: recipes still resolve (ItemClassModifier extends
ItemClass, names live in the shared ItemClass.nameToItem, Recipe looks them up
through GetItemClass); saves are safe (ItemClass.assignIdsFromMapping takes ids
from the stored name->id mapping, so shuffling configs cannot swap the item); the
Harmony patch and the localization keys go by name, and the name did not change.

THE TRAP IN MOVING IT: a modifier's effect_group applies to the item it is
INSTALLED IN - the 1000 durability would have been granted to the BRACELET. The
passive is gated with tags="necroBloodFlask" and the tag added to the flask's own
Tags: MaxUseTimesBase calls GetValue with the ItemTags of whatever it is
computing for.

THE BRACELET HAS 1 MOD SLOT, down from 4. The slot stopped being a set of
upgrades and became a choice.

AN EMPTY SLOT REFUSES. The check is the first line of Begin, ahead of every other
refusal: the others are about the TARGET (no block, wrong block, vault full), this
one is about the TOOL, and saying "no block there" when the real problem is an
empty bracelet sends the player looking in the wrong place. The test is
ItemValue.HasMods(), the game's own: it walks Modifications only, skipping nulls
and IsEmpty, and does not count CosmeticMods - a dye would otherwise have read as
"loaded". The refusal sound came free: Deny() in this file already plays vanilla's
ui_denied.

THE COST. The price of a pull is computed ONCE, at the start, and carried in
PickupJob.ChannelSeconds along with the bracelet itself. Not for brevity:
ChannelSecondsFor measures the player's ray, and ten seconds is long enough to
turn away - a second call would charge for a different block than the one taken.
The bracelet is kept as an instance for the same reason: mods live on the
ItemValue and the hotbar scrolls.

It is charged in SpendBlood, AFTER SetBlockRPC and after the item is in the vault:
every refusal returns earlier, so blood charged for a cancelled pull is impossible
by construction. The item name comes from NecromancerBloodPatch.BloodItemName
rather than a second literal, so the two cannot drift. A mod that is not blood
pays nothing and does not block the pull - the slot is meant for other things.

RUNS OUT, SHATTERS. The rule is "durability 0 or less", not "could not cover the
pull", and the difference is not cosmetic: under the second wording a flask with
exactly enough left would sit in the slot at zero, HasMods() would see "something
installed", and the bracelet would work for free forever. So the MaxUseTimes clamp
is gone and the slot is cleared with ItemValue.None - type 0, exactly what
IsEmpty() tests. The last pull always completes; the flask simply does not survive
it. The sound is vanilla's itembreak, the same cue ItemAction.HandleItemBreak
plays.

A guard: if MaxUseTimes comes out 0 (the passive effect or the tag removed), the
flask is NOT deleted and a warning naming the fix goes to the log. Without that
branch a config error would eat the player's item on the first pull and look like
a bug in the mechanic.

Localization: a new braceletSpatialVaultNoMod key in 13 languages, plus the flask
and bracelet descriptions - the mechanic became conditional and paid, and both
texts would have been lies without it.

Known and deliberate: the Blood Stone the empty-slot message promises does not
exist yet - it is planned, written up in BACKLOG.md. None of this is tested in
game.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 22:32:59 +03:00
AlexCubeandClaude Opus 5 20af2bbe6c Торговец снова становится призраком после выгрузки чанка
Баг со стрима: торговец, ставший чёрно-белым и полупрозрачным, наутро снова
обычный человек. Догадка была про обновление ассортимента - не подтвердилась, и
это стоит записать, потому что по часам лавки модель не трогает ВООБЩЕ ничего:
TraderData при сбросе переписывает только PrimaryInventory и lastInventoryUpdate,
а TraderArea.SetClosed ходит по блокам TraderOnOff - двери, замки, BlockLight,
динамик - и до сущности торговца не дотягивается.

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

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

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

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

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

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

---

Traders go back to being ghosts after a chunk unload

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

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

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

Prune drops destroyed entries from Converted and TintedMaterials. Without it both
lists would grow by one trader's worth per rebuild and Retint/Reapply would be
walking the wreckage. A material assigned through renderer.materials is owned by
that renderer and dies with it, so one pass settles both.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MnwP2Dt1vk8bUPJ452EoVL
2026-09-15 22:31:41 +03:00
AlexCubeandClaude Opus 5 7172681353 Версия 1.1.0: описания под забор блоков и торговцев
Номер по публичной линии Nexus: после 1.0.1 идёт 1.1.0, а не 1.0.2 - вышла не
починка, а две новые механики.

Описания приведены в соответствие с модом во всех четырёх местах, где они живут:
обе половины README, обе половины SITE_DESCRIPTION.html и NEXUS_DESCRIPTION.bbcode.

- Забор блоков дописан в пункт про Пространственный браслет: десять секунд
  вплотную плюс секунда за блок расстояния, тот же круглый индикатор, что у
  разбора верстака, обесцвечивание мира на время ожидания, перечень того, что не
  поддаётся, и прерывание силовой атакой. До этой правки там стояло "обычная
  атака пока ничего не делает".
- Торговцы - отдельным пунктом в "По мелочи", а на Nexus своим абзацем в тон
  остальному тексту: чёрно-белые, полупрозрачные и матовые, торговля не меняется.
- Счётчик строк 123 -> 130 в трёх местах. Он врал и до сегодня: ключей было 124.

ЗАОДНО ЭТО ЧИСЛО СТАЛО ЧЕСТНЫМ. Рядом со счётчиком все описания утверждают "пустых
ячеек нет", и это было неправдой: у txName_NecroAsh пустовали семь языков
(японский, корейский, польский, бразильский, турецкий и оба китайских).
Заполнены - теперь в файле 130 ключей и ноль пустых ячеек перевода.

Раздел "Статус" переписан под 1.1.0, прежний текст про 1.0.1 сохранён абзацем
ниже: он не устарел, он просто стал предысторией.

NEXUS_FILE_DESCRIPTION_1.0.1.bbcode намеренно не тронут - он описывает конкретную
уже залитую загрузку. Новой версии понадобится свой файл.

---

Version 1.1.0: descriptions updated for the block pull and the traders

The number follows the public Nexus line: after 1.0.1 comes 1.1.0 rather than
1.0.2, because what shipped is two new mechanics, not a fix.

The descriptions now match the mod in all four places they live: both halves of
the README, both halves of SITE_DESCRIPTION.html, and NEXUS_DESCRIPTION.bbcode.

- The block pull was added to the Spatial Bracelet entry: ten seconds up close
  plus one per block of distance, the same circular indicator a workbench pickup
  uses, the world draining to black and white for the wait, what refuses to be
  taken, and the power attack as the interrupt. Until this change the text still
  said the regular attack does nothing.
- The traders got their own bullet under "Small things", and on Nexus their own
  paragraph in the tone of the rest: black and white, half-transparent and matte,
  with the trading itself unchanged.
- The string count went 123 -> 130 in three places. It was already wrong before
  today: there were 124 keys.

AND THAT NUMBER IS NOW HONEST. Next to the count, every description claims there
are no empty cells, which was untrue: txName_NecroAsh was missing seven languages
(Japanese, Korean, Polish, Brazilian Portuguese, Turkish and both Chinese). They
are filled in - the file now has 130 keys and zero empty translation cells.

The "Status" section is rewritten for 1.1.0, with the old 1.0.1 text kept as a
paragraph below it: it did not become wrong, it became backstory.

NEXUS_FILE_DESCRIPTION_1.0.1.bbcode is deliberately untouched - it describes one
specific upload that already happened. The new version will need its own file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
2026-09-14 16:32:46 +03:00
AlexCubeandClaude Opus 5 a6e19f9b97 Браслет утаскивает блоки в хранилище; обесцвечивание на время каналов
Обычная атака Пространственного браслета до сих пор ничего не делала. Теперь
она наводится на блок, показывает тот же круглый индикатор, что и демонтаж
верстака, и по его заполнении блок исчезает из мира и появляется в хранилище.

ВЕСЬ РЕЦЕПТ ВАНИЛЬНЫЙ. Block.TakeItemWithTimer и TakeItemWithTimerDone - это и
есть демонтаж верстака; взяты как есть, с двумя заменами: длительность и Bag
хранилища вместо рюкзака. Сообщения об отказе тоже ванильные
(ttRepairBeforePickup, ttBlockMissingPickup, ttCantPickupInUse,
ttWorkstationNotEmpty) - уже переведены на все языки игры, и игрок, хоть раз
забиравший верстак, знает, что они значат. Повреждённый блок отсекается первой
строкой, до открытия таймера: сообщение есть, индикатора нет.

Все проверки делаются ДВАЖДЫ, на старте и на финише: за десять секунд блок
можно расстрелять, выкопать, подменить, кто-то может открыть контейнер. Порядок
в финале принципиален - предмет кладётся в хранилище ПЕРВЫМ, и блок сносится,
только если он туда лёг; обратный порядок удалял бы блок из мира, когда
хранилище успело заполниться.

Цель - любой блок под прицелом, а не только то, что ваниль и так разрешает
поднимать. Отсюда две вещи, которых у узкого варианта не было бы: мультиблок
(дверь, кровать) приводится к родительской клетке через
multiBlockPos.GetParentPos, иначе половина модели осталась бы стоять; блоки без
предметной формы (ToItemValue пуст) отсекаются, иначе блок исчезал бы в обмен
на ничто.

СОДЕРЖИМОЕ КОНТЕЙНЕРОВ ПЕРЕЕХАТЬ НЕ МОЖЕТ: ItemStack в этой игре некуда
положить чужой инвентарь. Ваниль решает отказом, тем же и здесь, расширенным на
сундуки - в этой версии игры они моделируются композитным tile entity со
storage-фичей, поэтому вопрос задаётся фиче через
TryGetSelfOrFeature<ITileEntityLootable>.

ОТМЕНА СИЛОВОЙ АТАКОЙ, потому что десять секунд неподвижности после случайного
клика - долго, а ванильные выходы оба плохие: урон игрок не выбирает, а кнопка
активации не та, на которой уже лежит рука. Патч на XUiC_Timer.Update, как у
Синего портала, и вместе с его уроком: семантической PlayerActionsLocal.Secondary
недостаточно (модальное окно таймера держит фокус ввода - это выяснилось
багрепортом 29.08), поэтому рядом стоит сырой Input.GetMouseButtonDown(1).
Отдельная страховка от двойного открытия: отмена ловит НАЖАТИЕ, а обычная
силовая атака - ОТПУСКАНИЕ, и это одно нажатие.

ТЕРРИТОРИЯ ТОРГОВЦА И ДНО МИРА. Оба случая выглядят изнутри игры одинаково
("здесь ничего не ломается") и устроены совершенно по-разному. У торговца блоки
обычные, защищена ТЕРРИТОРИЯ: ваниль просто не зовёт DamageBlock внутри неё,
поэтому кирка не берёт, а браслет брал - он спрашивал про блок, а спрашивать
надо про место. Условие скопировано целиком, вместе с песочничной половиной
(World.SandboxUseTraderArea != Default || !IsWithinTraderArea): защита торговца
- серверная настройка, и сервер, который её выключил, не должен обнаружить, что
мод навязывает её сам. Дно мира - обратный случай: у бедрока CanDestroy=false на
МАТЕРИАЛЕ, и это спрошено как вопрос о материале, а не по имени блока.

Оба отказа с сообщением, хотя ваниль молчит: кирка, которая ничего не делает,
объясняет сама себя, а индикатор, который не появляется, выглядит как поломка
мода.

ДЛИТЕЛЬНОСТЬ РАСТЁТ С РАССТОЯНИЕМ - десять секунд вплотную, плюс секунда за
каждый полный блок. Расстояние не вычисляется заново: HitInfoDetails.distanceSq
- это квадрат длины ТОГО САМОГО луча, которым блок и выбран, а вычислять между
позициями значило бы выбрать точку в игроке (ноги? глаза?) и точку в блоке
(центр? грань?) и ошибиться хотя бы в одной. Пол, а не округление: только так
сходятся обе заданные точки - вплотную ровно 10, в пяти блоках ровно 15.

МИР ОБЕСЦВЕЧИВАЕТСЯ НА ВРЕМЯ ЛЮБОГО КАНАЛА - и утаскивания блока, и обоих
порталов (HarmonySrc/ChannelVision.cs, общий на оба, чтобы вид и время жили в
одном месте). Это штатный ScreenEffects игры: SetScreenEffect(name, intensity,
fadeTime), и плавность досталась даром - три секунды туда и три обратно это
третий аргумент. Эффект "Greyscale" выбран по тому, с кем НЕ придётся драться:
в него пишут только twitch_buffMonochrome и sandbox_blackandwhite, которых в
обычной сессии не бывает. "Dying"/"Dead" - те самые эффекты смерти, но их пишет
EntityPlayerLocal.Update из здоровья игрока при каждом изменении, и любой урон
посреди канала перехватил бы эффект. "Dark" дал бы затемнение, но принадлежит
buffCrouching и срабатывает на каждое приседание - поэтому затемнения нет
сознательно. Возврат красок вызывается на КАЖДОМ пути выхода, а в завершении
утаскивания - первой строкой, до всех проверок: десять секунд кончились и когда
блок забрали, и когда отказали.

Локализация: шесть новых ключей на 13 языков. Описание браслета переписано
(дважды за правку, вслед за механикой) - оно утверждало сначала "обычная атака
ничего не делает", потом "через десять секунд".

В игре проверено: забор блока работает. Отмена, обесцвечивание и рост
длительности - ещё нет.

---

The bracelet pulls blocks into the vault; colour drains during channels

The Spatial Bracelet's regular attack did nothing until now. It aims at a block,
shows the same circular indicator a workbench pickup does, and when it fills the
block leaves the world and appears in the vault.

THE WHOLE RECIPE IS VANILLA'S. Block.TakeItemWithTimer and TakeItemWithTimerDone
are the workbench pickup; taken as they are, with two substitutions - the
duration, and the vault's Bag instead of the backpack. The refusal messages are
vanilla's own keys too (ttRepairBeforePickup, ttBlockMissingPickup,
ttCantPickupInUse, ttWorkstationNotEmpty), already translated into every language
the game ships, and a player who has taken a workbench already knows them. A
damaged block is refused on the first line, before the timer opens: a message,
and no indicator.

Every guard is checked TWICE, once to open and once to finish: in ten seconds a
block can be shot, mined, replaced, or opened by someone else. The order at the
end matters - the item goes into the vault FIRST and the block is only removed if
it got there; the other way round deletes a block out of the world in exchange
for nothing when the vault filled up meanwhile.

The target is any block under the crosshair, not only what vanilla already lets
you take. Two things follow that the narrow version would never have faced: a
multiblock (a door, a bed) is resolved to its parent cell through
multiBlockPos.GetParentPos, or half the model would be left standing; and blocks
with no item form (ToItemValue comes back empty) are refused, or a block would
vanish in exchange for nothing.

CONTENTS CANNOT TRAVEL: an ItemStack in this game has nowhere to put another
container's inventory. Vanilla solves this by refusing, and so does this,
extended to chests - this version of the game models them as a composite tile
entity with a storage feature, so the question is asked of the feature through
TryGetSelfOrFeature<ITileEntityLootable>.

THE POWER ATTACK CANCELS, because ten seconds of standing still after a misclick
is long and vanilla's two escapes are both poor here: getting hit is not a
choice, and the activate key is not the button a hand is already on. The patch
sits on XUiC_Timer.Update like the Blue Portal Stone's, and carries its lesson:
the semantic PlayerActionsLocal.Secondary is not enough, because the modal timer
window holds input focus (found by a bug report on 29 Aug), so a raw
Input.GetMouseButtonDown(1) sits next to it. A separate guard stops one press
opening the vault twice: the cancel catches the button going DOWN, the ordinary
power attack catches it coming UP, and that is one press.

A TRADER'S GROUND AND THE WORLD'S FLOOR look identical from inside the game
("nothing breaks here") and are nothing alike underneath. A trader's blocks are
ordinary; it is the AREA that is protected - vanilla simply skips DamageBlock
inside it, which is why a pickaxe does nothing while the bracelet did not: it was
asking about the block when it had to ask about the place. The condition is
copied whole, sandbox half included (World.SandboxUseTraderArea != Default ||
!IsWithinTraderArea): trader protection is a server setting, and a server that
turned it off should not find this mod enforcing it anyway. The world's floor is
the opposite case - bedrock carries CanDestroy=false on its MATERIAL, and that is
asked as a question about the material rather than by block name.

Both refusals speak, where vanilla stays silent: a pickaxe that does nothing
explains itself, an indicator that never appears looks like this mod is broken.

THE CHANNEL GROWS WITH REACH - ten seconds up close, one more per full block.
The distance is not recomputed: HitInfoDetails.distanceSq is the squared length
of the very ray that chose this block, while measuring between positions would
mean picking a point in the player (feet? eyes?) and a point in the block
(centre? face?) and being wrong about one. Floor rather than round, because only
that makes both given anchors come out right - exactly 10 up close, exactly 15 at
five blocks.

COLOUR DRAINS OUT DURING ANY CHANNEL - the block pull and both portals
(HarmonySrc/ChannelVision.cs, shared so the look and the timing live in one
place). This is the game's own ScreenEffects: SetScreenEffect(name, intensity,
fadeTime), and the smoothness came free - three seconds each way is that third
argument. "Greyscale" was chosen by who else writes to it: only
twitch_buffMonochrome and sandbox_blackandwhite, neither of which happens in an
ordinary session. "Dying"/"Dead" are the death visuals being imitated, but
EntityPlayerLocal.Update writes "Dying" from the player's health on every change,
so any damage mid-channel would take it over. "Dark" would have supplied the
darkening half, but it belongs to buffCrouching and fires on every crouch - so
the darkening is deliberately absent. The colour is restored on EVERY exit path,
and in the pull's completion on the first line, before any check: the ten seconds
are over whether the block was taken or refused.

Localization: six new keys in 13 languages. The bracelet's description was
rewritten (twice in this change, following the mechanics) - it claimed first that
the regular attack does nothing, then that the pull takes ten seconds.

Confirmed in game: taking a block works. The cancel, the desaturation and the
distance scaling are not tested yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
2026-09-14 16:22:55 +03:00
AlexCubeandClaude Opus 5 275a739646 README: английская версия описания рядом с русской
На заглавной странице репозитория был только русский текст: английская
версия существовала, но лежала в SITE_DESCRIPTION.html - это исходник
страницы мода на сайте, в Gitea он не выводится никуда. Человек, пришедший
из Nexus, где описание как раз английское, попадал на русскую страницу.

Обе версии теперь в одном файле, английская после разделителя - той же
схемой, что уже применена в SITE_DESCRIPTION.html. Отдельный README.en.md
не заводился намеренно: Gitea показывает на заглавной ровно один файл, и
второй язык за ссылкой увидел бы только тот, кто догадается кликнуть.

Перевод сделан с README, а не скопирован из SITE_DESCRIPTION.html: тексты
не совпадают. В README подробнее арсенал (числа по модам ножа), есть ссылка
на ключи necroFinal* в Localization.csv и раздел "Статус", которых на сайте
нет вовсе. Все девять разделов зеркалят русские.

Под заголовком добавлена строка-указатель, чтобы англоязычный читатель не
листал вслепую длинную русскую половину, гадая, есть ли там что-то для него.

---

README: the English description alongside the Russian one

The repository's front page carried Russian only. An English version did
exist, but it lived in SITE_DESCRIPTION.html - the source of the mod's page
on the website, which Gitea renders nowhere. Anyone arriving from Nexus,
where the description is in English, landed on a Russian page.

Both versions now sit in one file, the English half after a separator - the
same shape already used in SITE_DESCRIPTION.html. A separate README.en.md
was deliberately not created: Gitea shows exactly one file on the front
page, and a second language behind a link is seen only by whoever thinks to
click it.

The translation is made from the README rather than copied out of
SITE_DESCRIPTION.html, because the two texts differ. The README goes into
more detail on the arsenal (the numbers on the knife mods), points at the
necroFinal* keys in Localization.csv, and has a "Status" section the website
text does not have at all. All nine sections mirror the Russian ones.

A pointer line under the title keeps an English reader from scrolling
blindly through the long Russian half wondering whether anything there is
for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
2026-09-14 15:14:01 +03:00
AlexCubeandClaude Opus 5 7768541f12 Торговцы-призраки: настоящая прозрачность тела, матовость, консоль
Прозрачность у торговцев управляла только бородой. Причина была напечатана
зондом ещё при первом заходе: у шейдера тела (Game/Character) нет НИ цвета с
альфой, НИ режима смешивания - оба рычага ApplyTransparency существуют только
у шейдера волос (Game/Autodesk). Никакое значение альфы тело бы не тронуло.

Сначала добавлен третий рычаг - _Fade, собственный рычаг игры:
EntityModel.SetFade пишет ровно это свойство и отбирает материалы проверкой
HasProperty("_Fade") && shader.name.Contains("Game/Character"), а стоящий рядом
SetVisible(renderFade > 0.01f) закрывает направление: 1 = сплошной, 0 = исчез.
В игре выяснилось, что шейдер реализует его screen-door дизерингом - "тело
гаснет, но идёт мелкой сеточкой". Рычаг рабочий, но пиксели не смешиваются, а
выбрасываются, и никакое число этого не исправит. Оставлен как режим dither.

РАБОЧЕЕ РЕШЕНИЕ - перешивка тела на шейдер волос, у которого есть прозрачный
проход. Доказательство лежало на той же модели в том же кадре: борода всё это
время гасла плавно. Шейдер берётся С МОДЕЛИ - первый материал, умеющий
смешивать (есть цвет с альфой И режим смешивания); Shader.Find оставлен
запасным путём, потому что мод уже дважды получал null/негатив от семейства
Unlit. Решение принимается по способностям материала, имя шейдера нигде не
зашито. Переносятся только альбедо и нормаль: _RMOE - упакованная
roughness/metallic/occlusion/emission, а _MetallicGlossMap ждёт металл в R и
гладкость в A, каналы не совпадают, и связывание "на авось" даёт мокрый пластик
или негатив. Цена названа прямо: тело теряет собственную световую модель
шейдера персонажа и получает стандартную.

МАТОВОСТЬ по просьбе пользователя - три рычага, ломаются по-разному:
_Glossiness в 0 (чистая математика шейдера, работает всегда - несущая
половина); _SpecularHighlights и _GlossyReflections в 0 ВМЕСТЕ с ключевыми
словами _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, потому что это
[ToggleOff]-пара и запись одного флоата не делает ничего; карты глянца
очищаются, если непусты, - карта побеждает флоат. Оба keyword'а -
shader_feature, Unity вырезает такие на сборке, если их не выставляет ни один
материал игры, поэтому шершавость сделана основным рычагом, а не запасным.
Применяется ко всем призрачным материалам: волосы нарисованы тем же шейдером и
сохраняли свой блеск, а матовое тело под глянцевой бородой читалось бы хуже.

КОНСОЛЬНАЯ КОМАНДА necroghost (алиас necrotrader): проценты прозрачности,
blend/dither, reset. Балансировать рендер можно только глядя на него, а шаг без
команды стоил пересборки, перезапуска и ~4 минут пешком до торговца. Проценты,
а не альфа: это единица, в которой просьба формулировалась, и они идут в разные
стороны. Регистрации нет и не требуется - SdtdConsole.RegisterCommands ищет
через ReflectionHelpers.FindTypesImplementingBase, который обходит
ModManager.GetLoadedAssemblies(), а LoadMods() стоит на три строки раньше
RegisterCommands(). IsExecuteOnClient = true: команда меняет пиксели.

Две мины, обе реальные. Повторное применение не должно умножать: материалы
кэшируются, и sharedMaterials после первого прохода возвращает наши же клоны,
так что наивный повторный свип дал бы 0.9, потом 0.8 = 0.72; запоминается
базовое значение, живое всегда base * GhostAlpha. Запятая: StringParsers не
зависит от локали, но читает "," как разделитель ТЫСЯЧ, и "necroghost 12,5"
молча стало бы 125 - запятая заменяется на точку до парсинга.

DefaultGhostAlpha 0.9 -> 0.3: 70% прозрачности, найденные в игре. Путь был
1% -> 10% -> 70%, и последний скачок не смена вкуса - на 10% тело ещё
дизерилось, а дизеринг терпим лишь пока слаб. Поэтому же переписана заметка
"ниже ~0.7 силуэт разваливается": предел принадлежал технике, а не глазу.

---

Ghost traders: the body really blends, matte finish, console command

The transparency only ever reached the beard. The probe had already printed
why: the body's shader (Game/Character) has NEITHER a colour with an alpha NOR
a blend mode - both of ApplyTransparency's levers exist only on the hair's
shader (Game/Autodesk). No value of the alpha was ever going to touch it.

A third lever was added first - _Fade, the game's own: EntityModel.SetFade
writes exactly that property and guards it with HasProperty("_Fade") &&
shader.name.Contains("Game/Character"), and the SetVisible(renderFade > 0.01f)
sitting next to it settles the direction: 1 = solid, 0 = gone. In game the
shader turned out to implement it as screen-door dithering - "the body fades,
but goes to a fine grid". The lever works, but pixels are thrown away rather
than blended, and no number fixes that. It is kept as the dither mode.

WHAT ACTUALLY WORKS is re-shading the body onto the hair's shader, which does
have a transparent pass. The existence proof was on the same model in the same
frame: the beard had been fading smoothly all along. The shader is taken OFF
THE MODEL - the first material that can blend (a colour with an alpha AND a
blend mode); Shader.Find is kept only as a fallback, because this mod has twice
been handed null or a negative by the Unlit family. The decision is made on
what a material can do; no shader name is hard-coded. Only albedo and normal
are carried over: _RMOE is a packed roughness/metallic/occlusion/emission map
while _MetallicGlossMap wants metallic in R and smoothness in A - the channels
do not line up, and wiring them by hope is how a character ends up looking like
wet plastic or a negative. The trade is stated plainly: the body loses the
character shader's own lighting response and gets standard lighting instead.

MATTE, as requested - three levers that fail differently: _Glossiness to 0
(plain shader maths, always works - the load-bearing half); _SpecularHighlights
and _GlossyReflections to 0 TOGETHER WITH the _SPECULARHIGHLIGHTS_OFF /
_GLOSSYREFLECTIONS_OFF keywords, because they are a [ToggleOff] pair and
setting the float alone does nothing; and the gloss maps cleared if anything is
in them, since a map beats the float. Both keywords are shader_feature, which
Unity strips at build time if no material in the game sets them - which is why
roughness is the main lever and not the fallback. Applied to every ghost
material: the hair uses the same shader and kept its own shine, and a matte
body under a glossy beard would have read worse than either.

CONSOLE COMMAND necroghost (alias necrotrader): transparency in percent,
blend/dither, reset. A rendering balance can only be judged by looking at it,
and without the command each step cost a rebuild, a restart and a four-minute
walk to a trader. Percent rather than alpha: percent is the unit the request
was made in, and the two run in opposite directions. No registration is needed
- SdtdConsole.RegisterCommands goes through
ReflectionHelpers.FindTypesImplementingBase, which walks
ModManager.GetLoadedAssemblies(), and LoadMods() runs three lines before
RegisterCommands(). IsExecuteOnClient = true: the command changes pixels.

Two real traps. Re-applying must not compound: materials are cached and
sharedMaterials hands back our own clones after the first pass, so a naive
second sweep would give 0.9, then 0.8 = 0.72; the base value is remembered and
the live one is always base * GhostAlpha. The comma: StringParsers is
culture-independent but reads "," as a THOUSANDS separator, so "necroghost
12,5" would silently have become 125 - the comma is turned into a point first.

DefaultGhostAlpha 0.9 -> 0.3: the 70% transparency settled on in game. The road
was 1% -> 10% -> 70%, and the last jump was not a change of taste - at 10% the
body was still dithering, and a dither is bearable only while it is faint. For
the same reason the old "below ~0.7 the silhouette falls apart" note was
rewritten: that limit belonged to the technique, not to the eye.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XN8J75vnum2qAVrtRUMKf7
2026-09-14 15:05:49 +03:00
AlexCubeandClaude Opus 5 9ac575075a Описание файла 1.0.1 для Nexus
Описание КОНКРЕТНОГО файла в разделе Files, отдельно от описания страницы
мода в NEXUS_DESCRIPTION.bbcode.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXvXg1FSAQJHrvYbeAKqq
2026-09-13 20:42:06 +03:00
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
41 changed files with 4438 additions and 192 deletions
File diff suppressed because one or more lines are too long
+95 -2
View File
@@ -66,10 +66,103 @@
<property class="TEFeaturePyramidWard" /> <property class="TEFeaturePyramidWard" />
</property> </property>
<property name="Material" value="Msteel_shapes" /> <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" /> <property name="Shape" value="New" />
<!-- Своя модель отключена вместе с возвратом на Shape="New" (см. комментарий выше).
Строка сохранена: она рабочая, ею подключается наш бандл. -->
<property name="Model" value="@:Shapes/pyramid.fbx" /> <property name="Model" value="@:Shapes/pyramid.fbx" />
<property name="Texture" value="356" /> <!-- <property name="Model" value="#@modfolder(NecromancerTome):Resources/necropyramid?necroPyramidPrefab.prefab" /> -->
<property name="TintColor" value="8A4142" /> <!-- 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 <!-- Real generated art (exch/pyramidOfSpirit.png, copied to
UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no UIAtlases/ItemIconAtlas/) added 2026-09-02, per direct instruction - no
CustomIconTint alongside it (same lesson as every other real icon in this mod, CustomIconTint alongside it (same lesson as every other real icon in this mod,
+2 -2
View File
@@ -11,7 +11,7 @@
<append xpath="/buffs"> <append xpath="/buffs">
<!-- Never expires (duration 0): a persistent lifetime total, not a per-day/session counter. --> <!-- Never expires (duration 0): a persistent lifetime total, not a per-day/session counter. -->
<buff name="buffNecroZombieKillTrackerDisplay" icon="ui_game_symbol_zombie" icon_color="150,0,0" name_key="buffNecroZombieKillTrackerDisplayName" description_key="buffNecroZombieKillTrackerDisplayDesc"> <buff name="buffNecroZombieKillTrackerDisplay" icon="ui_game_symbol_skull" icon_color="150,0,0" name_key="buffNecroZombieKillTrackerDisplayName" description_key="buffNecroZombieKillTrackerDisplayDesc">
<stack_type value="ignore"/> <stack_type value="ignore"/>
<duration value="0"/> <duration value="0"/>
<update_rate value=".1"/> <update_rate value=".1"/>
@@ -172,7 +172,7 @@
состояние кадра верное, но пульсация иконки может подёргиваться раз в секунду. Если состояние кадра верное, но пульсация иконки может подёргиваться раз в секунду. Если
будет заметно - убрать строку снятия из onSelfBuffUpdate и оставить чистку только на будет заметно - убрать строку снятия из onSelfBuffUpdate и оставить чистку только на
onSelfBuffRemove, ценой залипших меток на ушедших зомби. --> onSelfBuffRemove, ценой залипших меток на ушедших зомби. -->
<buff name="buffNecroDarkSense" name_key="buffNecroDarkSenseName" description_key="buffNecroDarkSenseDesc" icon="ui_game_symbol_zombie" icon_color="150,0,255"> <buff name="buffNecroDarkSense" name_key="buffNecroDarkSenseName" description_key="buffNecroDarkSenseDesc" icon="ui_game_symbol_skull" icon_color="150,0,255">
<stack_type value="replace"/> <stack_type value="replace"/>
<duration value="0"/> <duration value="0"/>
<update_rate value="1"/> <update_rate value="1"/>
+145
View File
@@ -0,0 +1,145 @@
<config>
<!-- ВКЛАДКА ИСПЫТАНИЙ "Некромантия" (указание 2026-09-16: "Сделать вкладку квестов для
некромантии. Типа тех вкладок, где дают опыт за установку первой двери, за установку
клеймблока, за повешение факела и сбор ресурсов").
ОБА ЗАКАЗАННЫХ ИСПЫТАНИЯ НА МЕСТЕ с 2026-09-16. Сначала тут было одно ("Первым делом -
нож"): challenges.xml мод не трогал ни разу, и сперва надо было увидеть в игре, что вкладка
вообще появляется. Нож выбран для этой проверки потому, что крафтится сразу и без верстака,
то есть проверка занимала минуту, а не сессию. Вкладка отобразилась, испытание закрылось,
лог чистый - после чего добавлен "Безумный донор".
РАЗРОЗНЕННО, НЕ ЦЕПОЧКОЙ (указание: "цепочку ивентов там делать не нужно. Пусть будет
разрозненно. Что сделано то и отмечаем"). Поэтому у группы НЕТ ни link_challenges="true",
ни hidden_by - оба атрибута стоят у ванильных Basics/Homesteading именно затем, чтобы
открывать записи по очереди. Без них испытания видны сразу и закрываются в любом порядке.
Побочная польза: снимается противоречие порядка - Кровь некроманта нельзя скрафтить без
ножа в инвентаре, так что "донор" первым в цепочке был бы невыполним.
ОТКУДА ИКОНКИ - разобрано 2026-09-16, и вывод ограничивающий: ТОЛЬКО из символьного
атласа игры, свой спрайт туда положить нельзя.
- В Data/Config/XUi_InGame/templates.xml оба места, где эти иконки рисуются, объявлены
БЕЗ атрибута atlas: строка испытания (challenge_entry, sprite="{iconname}") и кнопка
вкладки (category_icon, sprite="{categoryicon}"). Нет atlas - значит атлас по
умолчанию, тот, где живут все ui_game_symbol_*.
- Подтверждение с другой стороны: в ванильном challenges.xml ВСЕ 215 иконок имеют вид
ui_game_symbol_* без единого исключения (проверено вычитанием).
- Свой атлас мода (UIAtlases/NecroFinal) тут не поможет: механизм UIAtlases/<Имя>
создаёт НОВЫЙ атлас, и обратиться к нему можно только явным atlas="NecroFinal" на
спрайте, которого в этих шаблонах нет.
- Дописать atlas= в сам шаблон НЕЛЬЗЯ: шаблон один на все испытания, и тогда ванильные
215 иконок начнут искаться в нашем атласе и пропадут.
Выбирали из 359 доступных ui_game_symbol_*, собранных по ванильным конфигам. -->
<append xpath="/challenges">
<!-- ВКЛАДКА. Череп, и это ОДНО РЕШЕНИЕ С ИКОНКОЙ САМОГО СКИЛЛА "Некромантия" в
progression.xml: вкладка и скилл обязаны читаться как одно целое, поэтому меняются
только парой. Разойдутся - и панель скилла с вкладкой испытаний перестанут выглядеть
как одна система.
Сначала (16.09) здесь стоял ui_game_symbol_zombie - по тому же принципу совпадения со
скиллом, у которого он тогда и стоял. В игре вкладка отобразилась и испытание
сработало, после чего пользователь заменил символ на череп: "Для некроманта это более
атмосферно". Заменено разом во всех четырёх живых местах мода (здесь, скилл в
progression.xml и два баффа в buffs.xml), разбор - в BACKLOG.md.
Годные альтернативы, если череп когда-нибудь разонравится:
ui_game_symbol_specters_grace, ui_game_symbol_7th_curse,
ui_game_symbol_near_death_trauma. -->
<challenge_category name="NecroNecromancy" title_key="challengeCatNecroNecromancy" icon="ui_game_symbol_skull"/>
<!-- ГРУППА. Сознательно БЕЗ reward_text_key и reward_event.
Это осознанный риск скелета, а не забывчивость: у всех 20 ванильных групп они есть, но
корень <challenges> объявлен с default_reward="challenge_reward_default" и
default_reward_text_key="challenge_reward_100xp", и мы добавляемся именно в этот
корень. Если умолчания на группы не распространяются, игра скажет об этом в логе - и
это ровно то, ради чего скелет и запускается. Заводить своё событие награды вслепую
дороже, чем один раз посмотреть в лог. -->
<challenge_group category="NecroNecromancy" name="NecroPath" title_key="challengeGroupNecroPath"/>
<!-- ИСПЫТАНИЕ. objective type="Craft" без count - ножа нужен один.
hint не задан: он необязателен - в ванили одно испытание из 188 обходится без него, а
тут подсказка и не нужна, название говорит само за себя.
500 XP, А НЕ УМОЛЧАНИЕ. Сначала тут не стояло награды вовсе, то есть работал
challenge_reward_default (100 XP), по аналогии с ванильным craftStoneAxe: один крафт в
самом начале. Аналогия оказалась неверной, и это выяснилось только при чтении рецепта:
в ножа входит 1 Кровь некроманта, а её собственный крафт снимает 90% ТЕКУЩЕГО здоровья.
То есть нож стоит одного захода на порог смерти, чего у каменного топора нет и близко.
500 - ванильный тир простых, но небесплатных дел (craftCharredMeat, huntAnimals). -->
<challenge name="necroCraftKnife" title_key="challengeNecroCraftKnifeTitle" icon="ui_game_symbol_knife" group="NecroPath"
short_description_key="challengeNecroCraftKnifeShort" description_key="challengeNecroCraftKnifeDesc"
reward_text_key="challenge_reward_500xp" reward_event="challenge_reward_500">
<objective type="Craft" item="necroWpnBladeNecroKnife"/>
</challenge>
<!-- ИСПЫТАНИЕ 2: "Безумный донор" (указание: "добыть 15 флаконов крови некроманта").
Кровь не добывается из мира, она крафтится, поэтому тип Craft, а не Gather.
count="15" - у Craft счётчик поддерживается, и это стоило отдельной проверки: в самых
наглядных ванильных примерах (craftStoneAxe, craftClothes) он не указан, и легко
решить, что его нет. Есть - 18 ванильных Craft-целей его несут, вплоть до
resourceForgedIron count="100" и thrownAmmoPipeBomb count="20".
ИМЯ ОПРАВДАНО МЕХАНИКОЙ, А НЕ ПРОСТО КРАСИВОЕ: каждый крафт Крови некроманта снимает
90% ТЕКУЩЕГО здоровья (NecromancerBloodPatch.cs) и требует нож в инвентаре. Пятнадцать
раз подряд - это пятнадцать раз довести себя до полусмерти, и "Безумный донор" описывает
это точно.
Зависимости от первого испытания нет и быть не должно (указание: "разрозненно"), хотя
фактически кровь без ножа не скрафтить. Порядка у группы нет, так что игрок сам
упрётся в нож, когда попробует начать с крови, - и это честнее замка.
icon="ui_game_symbol_siphoning_strikes" - "высасывающие удары", символ выкачивания
жизни. Найден при разборе всех 359 доступных ui_game_symbol_*; под механику "плати
собственной кровью" попадает точнее любого медицинского символа. -->
<challenge name="necroMadDonor" title_key="challengeNecroMadDonorTitle" icon="ui_game_symbol_siphoning_strikes" group="NecroPath"
short_description_key="challengeNecroMadDonorShort" description_key="challengeNecroMadDonorDesc"
reward_text_key="challenge_reward_2500xp" reward_event="challenge_reward_2500">
<objective type="Craft" item="resourceNecromancerBlood" count="15"/>
</challenge>
<!-- ИСПЫТАНИЕ 3: "Первый подданный" - Камень духов (указание 2026-09-17: "И первый камень
духов. Сам посчитай и придумай").
Имя про механику: Камень духов подчиняет зомби, и подчинённый бьётся за игрока. Это
первое существо, которое слушается некроманта, - отсюда "подданный".
100 XP, то есть challenge_reward_default, награда НЕ ЗАДАНА СОЗНАТЕЛЬНО. Рецепт -
1 камень + 20 волокон травы, ни праха, ни крови. Это самая дешёвая вещь мода и
буквальный аналог ванильного craftStoneAxe, за который ваниль платит ровно умолчание.
Здесь 100 - не занижение, а единственное честное число: поднять его выше значило бы
платить за десять минут игры больше, чем ваниль платит за то же самое.
icon="ui_game_symbol_specters_grace" ("милость призрака") - предмет называется Камнем
ДУХОВ и подчиняет мёртвого, так что символ совпадает и по названию, и по смыслу. -->
<challenge name="necroSpiritStone" title_key="challengeNecroSpiritStoneTitle" icon="ui_game_symbol_specters_grace" group="NecroPath"
short_description_key="challengeNecroSpiritStoneShort" description_key="challengeNecroSpiritStoneDesc">
<objective type="Craft" item="thrownStoneSpirit"/>
</challenge>
<!-- ИСПЫТАНИЕ 4: "Короткая дорога домой" - Синий портальный камень (указание 2026-09-17).
Имя про механику: камень телепортирует к спальному мешку за десятисекундный канал и не
расходуется. Это в буквальном смысле короткий путь домой, и никакой другой предмет мода
этого не делает.
2000 XP - и это САМОЕ ДОРОГОЕ ИСПЫТАНИЕ ВКЛАДКИ ПО ГРАЙНДУ, что видно только из
рецепта: 1 камень + 3 минералки + 15 волокон + 150 ПРАХА ЗОМБИ. Прах падает с Жертвы,
помеченной Ножом некроманта, по 1-4 с вероятностью 0.4 (loot.xml), то есть в среднем
около одной единицы за убийство - значит 150 праха это порядка ста пятидесяти
ритуальных убийств ножом. Для сравнения, ванильный craftForgedIron (сто крафтов
кованого железа) платит 2000, и это чистый грайнд без риска - ровно тот же случай.
Выше не ставим: 2500 у "Безумного донора" оплачивает смертельный риск, которого здесь
нет, а 5000 ваниль держит за химстанцией и тирами квестов.
icon="ui_game_symbol_map_house" - дом, к которому камень и возвращает. -->
<challenge name="necroPortalStoneBlue" title_key="challengeNecroPortalBlueTitle" icon="ui_game_symbol_map_house" group="NecroPath"
short_description_key="challengeNecroPortalBlueShort" description_key="challengeNecroPortalBlueDesc"
reward_text_key="challenge_reward_2000xp" reward_event="challenge_reward_2000">
<objective type="Craft" item="thrownStonePortalBlue"/>
</challenge>
</append>
</config>
+25 -13
View File
@@ -1,17 +1,29 @@
<config> <config>
<!-- Step 1: lifetime zombie kill counter. <!-- СЧЁТ УБИЙСТВ ПЕРЕЕХАЛ В КОД 2026-09-16. Здесь СОЗНАТЕЛЬНО ничего нет, и вернуть это
zombieTemplateMale is the root template every zombie entity_class extends обратно нельзя - см. HarmonySrc/NecromancyKillCreditPatch.cs.
(directly, or indirectly via zombieTemplateShort), so patching it here covers
every zombie variant in the game without listing them individually. --> Тут стоял append на zombieTemplateMale с двумя onOtherKilledSelf-эффектами
<append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']"> (ModifyCVar necroZombieKillsCVar и AddProgressionLevel craftingNecroNecromancy) под общим
<effect_group> требованием EntityTagCompare target="other" tags="player". Он был сломан дважды:
<requirement name="EntityTagCompare" target="other" tags="player"/>
<triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" cvar="necroZombieKillsCVar" operation="add" value="1"/> 1. Один класс - не все зомби. Пять зомби-ЗВЕРЕЙ наследуют животную ветку и до
<!-- "Некромантия" skill: +1 level per zombie kill, capped by its own max_level. zombieTemplateMale не доходят вовсе (animalZombieBear extends animalBear,
See progression.xml for why this drives the skill instead of reading books. --> animalZombieBoar extends animalBoar, animalZombieDog extends animalWolf,
<triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" progression_name="craftingNecroNecromancy" level="1"/> animalZombieVulture extends animalTemplateHostile, animalZombieVultureRadiated).
</effect_group> Убийство зомбопса, зомбомедведя, зомбокабана и зомбоворона не считалось никак.
</append> 2. target="other" - это БУКВАЛЬНЫЙ убийца, а не тот, кому ваниль зачла убийство.
Робомолот, кровотечение и питомцы требование tags="player" не проходят, хотя опыт
игрок за них получает: ваниль определяет получателя по DamageSource, в
EntityAlive.AwardKillXPServer.
Требование было одно на оба эффекта, поэтому вместе со скиллом недосчитывался и
necroZombieKillsCVar - то есть занижался урон Ножа некроманта (items.xml: Damage =
necroZombieKillsCVar / 10).
ОБА эффекта теперь делает Postfix на EntityPlayer.AddKillXP - единственной точке, где
ваниль уже решила, чей это фраг. ЕСЛИ ВЕРНУТЬ ЭТОТ append НА МЕСТО, убийство своей рукой
будет засчитано ДВАЖДЫ: и здесь, и в патче. Ровно это и проверять, если уровень вдруг
начнёт расти по два за труп. -->
<!-- "Зомбособака" (Zombie Dog pet): BACKLOG.md item 3. Extends the vanilla hostile <!-- "Зомбособака" (Zombie Dog pet): BACKLOG.md item 3. Extends the vanilla hostile
animalZombieDog (same prefab/physics/sounds - a real zombie dog model, not a reskinned animalZombieDog (same prefab/physics/sounds - a real zombie dog model, not a reskinned
+210 -7
View File
@@ -131,11 +131,21 @@
Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой Найденные параметры - HypothermalResist (холод) и HyperthermalResist (жара). Живой
ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml ванильный образец: modArmorInsulatedLinerT1/T2/T3 (Data/Config/item_modifiers.xml
~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3, ~1873), они ставят ровно эту пару. Величина у них по тирам: T1 1->2.5, T2 2.8->4.3,
T3 4.6->6 на ОДИН элемент брони, а элементов четыре. Взято 5 - примерно уровень T3 4.6->6 на ОДИН элемент брони, а элементов четыре.
одной детали брони с T3-подкладкой, и ровно то число, которое ваниль использовала во
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh (там 5 на холод и 5 на ЗНАЧЕНИЕ 5 -> 50, 2026-09-13, прямое указание пользователя ("по факту она поднимает
жару, но двумя РАЗНЫМИ модами; здесь оба в одном, что щедрее - но это стоит слота из сопротивление всего на 5, а надо на 50"). Изначально стояло 5 - примерно уровень одной
четырёх и работает только с ножом в руках, см. ниже). Крутить это число - одна правка. детали брони с T3-подкладкой, и ровно то число, которым ваниль пользуется во
вкомментированных modArmorInsulatedLiner/modArmorCoolingMesh. Это было осознанно
скромно; пользователь хочет иначе, и его решение тут главнее моей балансной оценки.
ЧТО 50 ОЗНАЧАЕТ НА САМОМ ДЕЛЕ, раз единица - градусы, а не проценты (формула ниже):
любая уличная температура в пределах 50 градусов от комфортных 70 подтягивается К 70
ЦЕЛИКОМ, потому что там стоит min/max-ограничение. То есть от 20 до 120 по шкале игры
это не "сильная защита", а полный иммунитет: и снежная вершина, и пустынный полдень
перестают быть угрозой. Это примерно в 10 раз больше, чем даёт набор брони с
T3-подкладками на всех четырёх деталях. Записано не в укор, а чтобы через месяц не
пришлось гадать, почему термометр перестал что-либо значить.
ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону ЕДИНИЦА ИЗМЕРЕНИЯ - градусы, на которые сдвигается уличная температура в сторону
комфортной, а не проценты (PlayerEntityStats, декомпиляция): комфортной, а не проценты (PlayerEntityStats, декомпиляция):
@@ -173,8 +183,8 @@
<property name="SellableToTrader" value="false"/> <property name="SellableToTrader" value="false"/>
<effect_group tiered="false"> <effect_group tiered="false">
<passive_effect name="HypothermalResist" operation="base_add" value="5"/> <passive_effect name="HypothermalResist" operation="base_add" value="50"/>
<passive_effect name="HyperthermalResist" operation="base_add" value="5"/> <passive_effect name="HyperthermalResist" operation="base_add" value="50"/>
</effect_group> </effect_group>
</item_modifier> </item_modifier>
@@ -314,4 +324,197 @@
</item_modifier> </item_modifier>
</append> </append>
<!-- "Кровавая сфера" (Blood Sphere) - расходный заряд Пространственного браслета.
Название и рецепт продиктованы 2026-09-15: «Кровавая сфера. Доступна на первом грейде.
Станки не нужны. Ингридиенты: Кровь некроманта, 5 праха зомби. По одному рецепту
изготавливается две сферы. Прочность сферы 500.»
ЗАЧЕМ ОНА ВООБЩЕ СУЩЕСТВУЕТ - и почему это НЕ «кровь, вставляемая в браслет». 15.09.2026
кровь перенесли в этот файл, чтобы она вставлялась в браслет, и это уничтожило персонажа в
сейве вместе с бэкапом: класс предмета определяет байтовую раскладку его стака
(ItemValue.Read:1094 / Write:1228 - обычный предмет пишет байт числа модификаций,
ItemClassModifier не пишет), и старый сейв стал нечитаемым. Полный разбор - в BACKLOG.md и
в большом предупреждении у крови в items.xml.
Сфера обходит это тем, что она НОВЫЙ предмет: в старых сейвах её нет, значит нет и ни
одного стака, который читался бы по другой раскладке. Это общее правило, а не уловка:
нужна модификация - заводи новый предмет, никогда не переводи существующий.
Атрибуты - по образцу модов ножа:
installable_tags="necroBracelet" - иначе модификация лезла бы в ЛЮБОЙ предмет
(CanSwap короткозамыкается на InstallableTags.IsEmpty);
modifier_tags="necroBraceletSphere" - свой, чтобы не конкурировать с будущим Кровавым
камнем через MaxModsAllowed;
blocked_tags НЕ задан - у браслета в тегах "noMods", объявить его тут значило бы
заблокировать самому себе установку;
type="attachment" - сферу можно вынуть обратно. -->
<append xpath="/item_modifiers">
<item_modifier name="resourceBloodSphere" installable_tags="necroBracelet" modifier_tags="necroBraceletSphere" type="attachment">
<!-- Extends на modGeneralMaster - та же база, что у шести модов ножа: даёт Group
"Mods", звуки mod_grab/mod_place, Stacknumber 1 и CreativeMode None.
param1="CustomIcon" исключает наследование родительского missingIcon.
РАНЬШЕ ЗДЕСЬ СТОЯЛ Extends="resourceRockSmall", И ЭТО БЫЛА ОШИБКА. Камень нужен
был только ради вида, а вместе с ним приезжали Action0 Class="ThrowAway",
ThrowableDecoy="true" и DistractionTags - то есть сферу можно было бы метать как
отвлекающий камень. Меш берётся строкой Meshfile ниже; наследовать ради него
весь предмет не нужно. -->
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="DescriptionKey" value="resourceBloodSphereDesc"/>
<!-- В РУКЕ - КАМЕНЬ С АЛЫМ ТИНТОМ (указание 2026-09-15: «в руке и сфера и кровавый
камень пусть будут как камень с алым тинтом»).
Это уже проверенный в этом моде путь, а не догадка: Камень духов, Синий и Чёрный
порталы (items.xml) сидят ровно на этом меше с ровно таким тинтом и в игре
работают - зелёный, синий и чёрный камни соответственно. Поэтому взят их набор
целиком: HoldType 40 плюс ВСЕ ТРИ меша. Три, а не один, потому что это три разные
ситуации - Meshfile общий, HandMeshfile в руке, DropMeshfile лежащим на земле, и
у тех трёх предметов они выписаны явно именно поэтому.
TintColor - ТРИПЛЕТ "R, G, B", а НЕ hex. Это другая ручка, чем CustomIconTint
выше (там hex): ItemClass парсит их разными путями - Color32 через запятые против
ParseHexColor. Перепутать легко, и на модах ножа это уже стоило круга.
ПОЧЕМУ ЗДЕСЬ ТИНТ РАБОТАЕТ, А НА БАНКЕ КРОВИ НЕ СРАБОТАЛ. 10.09 тинт предмета на
меш чая из золотарника не подействовал вообще: у шейдера Game_EntityTintMaskSSS
выигрывает собственный _Color материала. У rock_smallPrefab такого конфликта нет -
доказательство лежит в самом моде, три перекрашенных камня в игре видны. -->
<property name="HoldType" value="40"/>
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="HandMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="TintColor" value="220, 30, 45"/>
<!-- Своя рисованная иконка, получена 2026-09-15 (exch/bloodSphere.png, 160x160 RGBA,
как все остальные 28). Заглушка со спрайтом Камня духов и багровым тинтом,
стоявшая тут несколько часов, снята.
CustomIconTint НЕ ЗАДАЁТСЯ, и это тот же принцип, что у всех рисованных иконок
мода (шесть модов ножа, Кровь, Жертвенная кожа): тинт существует, чтобы
заимствованный ванильный спрайт не читался как предмет, из которого он взят. На
готовой работе он бы просто её затемнил. -->
<property name="CustomIcon" value="BloodSphere"/>
<!-- Прочность 500 (указание). Две ручки, обе обязательны - тот же разбор, что у крови
в items.xml: ShowQuality рисует полоску, а само число идёт пассивкой
DegradationMax. Без пассивки прочность равна нулю, а полоска при MaxUseTimes == 0
рисуется ПОЛНОЙ, то есть забытый эффект выглядит как "всё работает".
tiered="false" обязателен: HasQuality читается как Effects.IsOwnerTiered(), и
тированная группа превратила бы сферу в предмет с качеством - тиры, рамка.
Гейта по тегу здесь, в отличие от крови, НЕ НУЖНО. У модификации effect_group
применяется к предмету-хозяину, и у крови пассивку приходилось гейтить, чтобы
1000 прочности не досталась браслету. Сфера же и есть расходник браслета: пусть
он её и тратит. Браслету от DegradationMax ничего не будет - ему эту прочность
никто не списывает (SpendCharge трогает только сферу), а полоски у него нет. -->
<property name="ShowQuality" value="true"/>
<!-- true: опустевшая сфера должна исчезать, а не лежать "сломанной" в ожидании
ремонта. Само удаление из слота делает SpendCharge - см. SpatialVaultPickupPatch. -->
<property name="DegradationBreaksAfter" value="true"/>
<effect_group name="resourceBloodSphere" tiered="false">
<passive_effect name="DegradationMax" operation="base_set" value="500"/>
</effect_group>
<!-- Stacknumber 1 приходит из modGeneralMaster, своя строка не нужна. -->
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
</item_modifier>
</append>
<!-- КРОВАВЫЙ КАМЕНЬ (Blood Stone), продиктовано 2026-09-15, сделано 2026-09-16: «По сути это
будет тот же флакон, но с бесконечной прочностью и иконкой камня с алым тинтом.»
Эндгейм-версия расходника браслета: то же, что Кровавая сфера, только не тратится.
ПОЧЕМУ ЭТО БЫЛО СРОЧНО, А НЕ «ФИЧА ИЗ СПИСКА». Сообщение о пустом слоте
(braceletSpatialVaultNoMod) и описание браслета с 15.09 обещают игроку «кровавую сферу
ИЛИ кровавый камень» на всех 13 языках. Пока камня не было, отгруженный текст отправлял
за предметом, которого в игре нет. Так что эта правка - ещё и починка текста.
ПОЧЕМУ ЭТО БЕЗОПАСНО ДЛЯ СЕЙВОВ, и это здесь главный вопрос, а не побочный.
15.09 перенос Крови некроманта из items.xml сюда уничтожил персонажа в сейве: ItemValue
гейтит блок модификаций одним условием на чтении (Read, ~1094) и на записи (Write, ~1228)
- "!(itemClass is ItemClassModifier)", то есть КЛАСС ПРЕДМЕТА ОПРЕДЕЛЯЕТ БАЙТОВУЮ
РАСКЛАДКУ КАЖДОГО ЕГО СТАКА В СЕЙВЕ. Обычный предмет пишет байт "сколько модификаций",
ItemClassModifier не пишет ничего; после переезда читатель этот байт пропустил, поток
съехал, ближайший ReadString() упал, и .ttp с .ttp.bak умерли оба.
resourceBloodStone - НОВОЕ имя. В сейвах, записанных до этой правки, нет ни одного его
стака, значит нет и ни одной записи, которую пришлось бы читать по другой раскладке.
Именно поэтому камень заведён новым предметом, а не переводом уже существующего - тем же
решением, что и Кровавая сфера. Правило целиком: раздел про аварию в BACKLOG.md.
Атрибуты - по образцу сферы:
- installable_tags="necroBracelet" - обязательно. У CanSwap есть короткое замыкание
"InstallableTags.IsEmpty || ...", то есть модификация БЕЗ этого атрибута лезет в любой
предмет игры.
- modifier_tags СВОЙ (necroBraceletStone, не ...Sphere). Мод с совпадающим modifier_tags
считается против ItemClass.MaxModsAllowed - разбор в шапке этого файла. Слот у
браслета сейчас один, так что практической разницы нет, но правило "свой тег на
каждую модификацию" в моде записано, и ломать его незачем.
- blocked_tags НЕ задаётся: у браслета в тегах noMods, отрицательную половину он
обеспечивает сам.
-->
<append xpath="/item_modifiers">
<item_modifier name="resourceBloodStone" installable_tags="necroBracelet" modifier_tags="necroBraceletStone" type="attachment">
<!-- Та же база, что у сферы и у шести модов ножа: Group "Mods", звуки
mod_grab/mod_place, Stacknumber 1, CreativeMode None. param1="CustomIcon"
исключает наследование родительского missingIcon.
НЕ наследовать resourceRockSmall ради вида камня: вместе с видом приедут
Action0 Class="ThrowAway", ThrowableDecoy="true" и DistractionTags, то есть
камень можно будет метать как отвлекающий. Меш берётся строками ниже. На сфере
эта ошибка уже была и была исправлена 15.09. -->
<property name="Extends" value="modGeneralMaster" param1="CustomIcon"/>
<property name="DescriptionKey" value="resourceBloodStoneDesc"/>
<!-- В РУКЕ - КАМЕНЬ С АЛЫМ ТИНТОМ (указание 2026-09-15: «в руке и сфера и кровавый
камень пусть будут как камень с алым тинтом»). Набор скопирован со сферы, а та -
с трёх камней мода (Камень духов, Синий и Чёрный порталы), которые на этом же
меше с этим же тинтом в игре видны зелёным, синим и чёрным.
Три меша, а не один, потому что это три разные ситуации: Meshfile общий,
HandMeshfile в руке, DropMeshfile лежащим на земле.
TintColor - ТРИПЛЕТ "R, G, B", а не hex; hex берёт CustomIconTint, и это другая
ручка (Color32 через запятые против ParseHexColor). Перепутать легко, на модах
ножа это уже стоило круга. -->
<property name="HoldType" value="40"/>
<property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="HandMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="TintColor" value="220, 30, 45"/>
<!-- Своя рисованная иконка, лежит с 15.09 в обеих папках атласа
(UIAtlases/ItemIconAtlas/BloodStone.png + ItemIconAtlasGreyscale/BloodStone.png).
Серая копия обязательна: заблокированная запись в панели скилла рисуется из
greyscale-атласа, без неё у записи не было бы картинки вообще.
CustomIconTint НЕ ЗАДАЁТСЯ - тинт нужен заимствованным ванильным спрайтам, на
готовой работе он бы её просто затемнил. Тот же принцип, что у сферы. -->
<property name="CustomIcon" value="BloodStone"/>
<!-- БЕСКОНЕЧНАЯ ПРОЧНОСТЬ СДЕЛАНА ОТСУТСТВИЕМ СТРОК, А НЕ БОЛЬШИМ ЧИСЛОМ.
Здесь СОЗНАТЕЛЬНО нет ни ShowQuality, ни DegradationBreaksAfter, ни effect_group
с DegradationMax - сравнить со сферой выше, у которой все три есть.
Почему этого достаточно: SpendCharge (HarmonySrc/SpatialVaultPickupPatch.cs)
списывает прочность ТОЛЬКО у модификации, чьё имя совпадает с ChargeItemName
("resourceBloodSphere"). Камень под это условие не попадает по определению -
цикл делает continue, UseTimes камня не трогает никто и никогда. Кода менять не
пришлось вообще; комментарий в SpendCharge это заранее и обещает («a mod that is
not the charge pays nothing, is not emptied, and the pull still happens»).
Почему НЕ выставлять огромный DegradationMax: полоска прочности, которая никогда
не двигается, хуже её отсутствия. Плюс ловушка, уже разобранная на флаконе крови:
при MaxUseTimes == 0 полоска рисуется ПОЛНОЙ, то есть забытая пассивка выглядит
как "всё работает".
Тег necroBloodFlask здесь тоже не нужен, но уже по другой причине, чем было
записано в спеке: после полного отката Крови некроманта (15.09) этого тега в моде
не осталось совсем - он существовал только чтобы гейтить пассивку прочности
флакона. Пункт снят сам собой. -->
<property name="EconomicValue" value="0"/>
<property name="SellableToTrader" value="false"/>
</item_modifier>
</append>
</config> </config>
+264 -28
View File
@@ -214,7 +214,31 @@
<triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm"> <triggered_effect trigger="onProjectileImpact" action="AddBuff" target="positionAOE" range="2" buff="buffNecroDeviatorCharm">
<requirement name="EntityTagCompare" target="other" tags="zombie"/> <requirement name="EntityTagCompare" target="other" tags="zombie"/>
</triggered_effect> </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"/> <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> </effect_group>
</item> </item>
</append> </append>
@@ -893,8 +917,61 @@
whether melee weapons with ShowQuality="true" (inherited from the base knife) 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 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 the next thing to dig into (possibly needs ShowQuality or a cosmetic-slot
workaround), not a guess to make blind right now. --> workaround), not a guess to make blind right now.
<property name="TintColor" value="0, 0, 0"/>
СОМНЕНИЕ СНЯТО 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: "добавь в нож слоты для <!-- MOD SLOTS ADDED 2026-09-07 (user request: "добавь в нож слоты для
модификаций... модификации там будут особые, именно для ножа модификаций... модификации там будут особые, именно для ножа
@@ -1211,31 +1288,79 @@
its mesh/material/hold/pickup-sound (reused wholesale, thematically it IS a bag of blood, its mesh/material/hold/pickup-sound (reused wholesale, thematically it IS a bag of blood,
just a darker/necromantic one) - CustomIcon still set explicitly even though Extends is just a darker/necromantic one) - CustomIcon still set explicitly even though Extends is
used, same lesson as every other item in this mod (Extends alone never gives a working used, same lesson as every other item in this mod (Extends alone never gives a working
icon, confirmed originally on the Knife). Reuses the REAL vanilla sprite named icon, confirmed originally on the Knife).
"medicalBloodBag" itself (that item has no CustomIcon of its own, so its sprite name
equals its item name) rather than a generated-art file, per direct instruction ("Иконка ЭТОТ ПРЕДМЕТ НИКОГДА НЕ ДОЛЖЕН СТАТЬ <item_modifier>. ЭТО НЕ СТИЛЬ, ЭТО СЕЙВЫ.
такая же как и у обычной крови, но тинт затемнённый") - only TintColor differs (dark, 15.09.2026 он был перенесён в item_modifiers.xml, чтобы вставляться в Пространственный
near-black red vs. no tint on the vanilla bag). браслет, и это уничтожило персонажа в тестовом мире - вместе с бэкапом. Разбор целиком в
BACKLOG.md, здесь суть, потому что соблазн повторить велик:
ItemValue.Read строка 1094: if ((version > 4 || HasQuality) && !(itemClass is ItemClassModifier))
ItemValue.Write строка 1228: if (!(ItemClass is ItemClassModifier))
Класс предмета решает БАЙТОВУЮ РАСКЛАДКУ каждого его стака в сейве: обычный предмет пишет
байт числа модификаций, ItemClassModifier - не пишет. Значит любой сейв, записанный до
переноса, после переноса читается со сдвигом: поток съезжает, ближайший ReadString()
получает мусор, PlayerDataFile.Load падает с "output char buffer is too small", и игра
откатывается на NewGame. Бэкап .ttp.bak умирает вместе с основным файлом - он старого
формата ровно так же. Кровь лежит в сейвах у всех, кто поставил 1.1.0 с Nexus.
Правило на будущее: предмет, который уже мог попасть в чужой инвентарь, нельзя переводить
между ItemClass и ItemClassModifier ни в какую сторону. Нужна модификация - это НОВЫЙ
предмет с новым именем, которого в старых сейвах нет. Проверка "айди не поедут"
(assignIdsFromMapping) к этому отношения не имеет и ничего тут не гарантирует - именно на
неё я и посмотрел вместо раскладки.
Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно Crafting rules ("для создания нужна пустая банка и наличие любого ножа. При крафте нужно
отнимать у персонажа 90% имеющегося ХП") - the jar is a normal recipe ingredient (see отнимать у персонажа 90% имеющегося ХП") - the jar is a normal recipe ingredient (see
recipes.xml), but "any knife present, not consumed" and "cost 90% of current HP" have NO recipes.xml), but "any knife present, not consumed" and "cost 90% of current HP" have NO
vanilla XML equivalent (recipes.xml has no per-ingredient "required but not consumed" flag, vanilla XML equivalent (recipes.xml has no per-ingredient "required but not consumed" flag,
and crafting a resource has no HP-cost hook at all) - both enforced in and crafting a resource has no HP-cost hook at all) - both enforced in
HarmonySrc/NecromancerBloodPatch.cs instead. See that file for the exact decompiled HarmonySrc/NecromancerBloodPatch.cs instead. -->
mechanism and an important caveat about ingredient-refund timing that's flagged there, not
glossed over. -->
<append xpath="/items"> <append xpath="/items">
<item name="resourceNecromancerBlood"> <item name="resourceNecromancerBlood">
<property name="Extends" value="medicalBloodBag"/> <property name="Extends" value="medicalBloodBag"/>
<property name="DescriptionKey" value="resourceNecromancerBloodDesc"/> <property name="DescriptionKey" value="resourceNecromancerBloodDesc"/>
<!-- Custom art delivered 2026-08-30 (exch/NecromantsBlood.png, 160x160, copied to <!-- Своя рисованная иконка, 2026-08-30. CustomIcon задаётся явно даже при Extends. -->
UIAtlases/ItemIconAtlas/) - replaces the earlier placeholder that reused the
vanilla medicalBloodBag sprite with a darkened tint. No CustomIconTint here,
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="CustomIcon" value="NecromantsBlood"/>
<property name="TintColor" value="60, 0, 10"/>
<!-- Своя банка с кровью, 2026-09-10 (указание: «берём чай из золотарника, и жёлтое
заменяем на кровавый цвет, с фиолетовыми оттенками»). Заодно чинилось расхождение
текста и модели: описание говорит «Банка, наполненная кровью», а наследуемый
medicalBloodBag показывал sackPrefab - обычный мешок.
ПОЧЕМУ НЕ ХВАТИЛО ТИНТА - ПРОВЕРЕНО В ИГРЕ. Ванильный префаб чая плюс TintColor:
банка осталась чаем из золотарника, тинт предмета на этот меш НЕ ПОДЕЙСТВОВАЛ
ВООБЩЕ - у шейдера Game_EntityTintMaskSSS выигрывает собственный _Color материала.
Поэтому TintColor здесь не задаётся совсем.
И по сути: кровь отличается от чая не цветом, а тем, что она непрозрачная, тёмная
и густая, с плёнкой на стекле. Жидкость ПЕРЕРИСОВАНА по яркости, а не перекрашена
множителем - генератор _private/tools/make_necroblood_textures.py.
HoldType 3 - хват банки вместо 45 (мешок), Material Mglass - стекло вместо ткани. -->
<property name="Meshfile" value="#@modfolder(NecromancerTome):Resources/necroblood?necroBloodPrefab.prefab"/>
<property name="HoldType" value="3"/>
<property name="Material" value="Mglass"/>
<!-- НИ Stacknumber, НИ ПРОЧНОСТИ ЗДЕСЬ НЕТ, И ЭТО НАМЕРЕННО.
15.09.2026 крови на один заход выдали и то, и другое: стак по одной банке и
прочность 1000. Обе правки существовали ради одного - кровь должна была стать
расходником, вставляемым в Пространственный браслет. Эта затея откачена (она
ломала сейвы, см. предупреждение выше), расходником стала Кровавая сфера, и по
прямому указанию пользователя кровь возвращена к тому, чем была:
- стак снова 15 - наследуется от medicalBloodBag, своей строки Stacknumber
больше нет. Своя строка была нужна только чтобы перебить наследуемое 15 на 1;
- прочности нет вовсе: ни ShowQuality, ни DegradationBreaksAfter, ни
effect_group с DegradationMax. Тратил её браслет; тратить стало некому, а
полоска, которая никогда не двигается, хуже, чем её отсутствие.
Кровь снова то, чем была с самого начала: ингредиент рецептов, и только.
Если прочность когда-нибудь понадобится - разбор обеих ручек (пассивка
DegradationMax плюс отдельное свойство ShowQuality, и ловушка с полной полоской
при MaxUseTimes == 0) лежит в BACKLOG.md, повторно раскапывать не нужно. -->
<property name="EconomicValue" value="0"/> <property name="EconomicValue" value="0"/>
</item> </item>
</append> </append>
@@ -1288,7 +1413,30 @@
rest of the mod's hand-drawn icons). --> rest of the mod's hand-drawn icons). -->
<append xpath="/items"> <append xpath="/items">
<item name="braceletSpatialVault"> <item name="braceletSpatialVault">
<property name="Tags" value="T0,weapon,attPerception"/> <!-- MOD SLOTS ADDED 2026-09-13 ("добавь хранилищу 4 слота под модификации. Сами
модификации реализуем потом"), УБАВЛЕНЫ ДО ОДНОГО 2026-09-15 - число стоит в
effect_group в самом низу этого предмета, здесь только теги. 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: "поверх пиктограмм некоторых <!-- ItemTypeIcon="melee" REMOVED 2026-09-07 (user report: "поверх пиктограмм некоторых
рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the рецептов стоят странные пиктограммы... то ли факел, то ли спичка"). This was the
small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe small badge drawn in the TOP-LEFT corner over the item's own icon in the recipe
@@ -1316,19 +1464,64 @@
(bundle, computer, forge, explosion, campfire, gunsmithing, book). --> (bundle, computer, forge, explosion, campfire, gunsmithing, book). -->
<property name="DescriptionKey" value="braceletSpatialVaultDesc"/> <property name="DescriptionKey" value="braceletSpatialVaultDesc"/>
<property name="CustomIcon" value="ProstranstvennoeHranilische"/> <property name="CustomIcon" value="ProstranstvennoeHranilische"/>
<!-- Same "seed"-style grip as braceletThiefLoop originally had (see that item's own <!-- МЕШ И ХВАТ, 2026-09-13. Просьба в два захода: сперва "пусть будет камень, а
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 свёрток-«семечко» parcelGenericPrefab.prefab (коробочка, перевязанная бечёвкой -
"Attachments" transform error that forced Thief's Loop onto a real weapon mesh - для браслета нелепо) + HoldType="31", и то и другое унаследовано от Петли вора.
no reason to change this one's mesh too. TintColor changed to green 2026-08-30
per direct request. --> ИТОГ: в руке НЕТ НИЧЕГО, только кулак. Пустой префаб собирать не пришлось - в
движке есть готовое свойство, и вся связка целиком списана с ванильного
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="Material" value="Morganic"/>
<property name="Meshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/> <property name="Meshfile" value="@:Other/Items/Crafting/rock_smallPrefab.prefab"/>
<property name="HandMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/> <property name="DropMeshfile" value="@:Other/Items/Misc/sack_droppedPrefab.prefab"/>
<property name="DropMeshfile" value="@:Other/Items/Food/parcelGenericPrefab.prefab"/>
<property name="TintColor" value="30, 200, 60"/> <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="Weight" value="0"/>
<property name="Stacknumber" value="1"/> <property name="Stacknumber" value="1"/>
<property name="EconomicValue" value="0"/> <property name="EconomicValue" value="0"/>
@@ -1341,6 +1534,49 @@
<property name="Class" value="Eat"/> <property name="Class" value="Eat"/>
<property name="Delay" value="0.3"/> <property name="Delay" value="0.3"/>
</property> </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: 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. With a single slot this no longer costs slots - but it still
matters, because a shared tag would ALSO make two different bracelet mods mutually
exclusive in ways nothing in the UI explains. Keep them distinct.
ОДИН СЛОТ, указание 2026-09-15 («убавь у пространственного браслета количество
слотов под модификации до одного»). Было 4, поставленные 2026-08-30 по прежнему
выбору пользователя («4 фиксированно»).
Что это меняет по сути: слот из «набора улучшений» превратился в ВЫБОР. Сейчас
единственный кандидат - флакон Крови некроманта (item_modifiers.xml, переехал туда
2026-09-15), так что выбирать пока не из чего; но каждая следующая модификация
браслета теперь конкурирует за один слот, а не добавляется к остальным. Это стоит
держать в голове при их придумывании - иначе получится набор, из которого всегда
берут одну и ту же.
Число только здесь. ModSlots - пассивка, а не свойство, и никакой другой файл на
него не смотрит; менять обратно - эта же строка. У Ножа некроманта свои 4 слота
(выше в этом файле, ~строка 1056) - их указание НЕ трогало. -->
<effect_group name="braceletSpatialVault">
<passive_effect name="ModSlots" operation="base_set" value="1"/>
</effect_group>
</item> </item>
</append> </append>
</config> </config>
+26 -3
View File
@@ -87,7 +87,7 @@
tags instead). necroNecromancyLvl20 stays - still the one legitimate one-off (see tags instead). necroNecromancyLvl20 stays - still the one legitimate one-off (see
above). --> above). -->
<append xpath="/progression/crafting_skills"> <append xpath="/progression/crafting_skills">
<crafting_skill name="craftingNecroNecromancy" max_level="5000" parent="attCrafting" name_key="craftingNecroNecromancyName" desc_key="craftingNecroNecromancyDesc" long_desc_key="craftingNecroNecromancyLongDesc" icon="ui_game_symbol_zombie"> <crafting_skill name="craftingNecroNecromancy" max_level="5000" parent="attCrafting" name_key="craftingNecroNecromancyName" desc_key="craftingNecroNecromancyDesc" long_desc_key="craftingNecroNecromancyLongDesc" icon="ui_game_symbol_skull">
<!-- display_entry block kept in the SAME append as the crafting_skill itself (unlike <!-- display_entry block kept in the SAME append as the crafting_skill itself (unlike
an earlier draft of this edit, which tried appending display_entry via a second, an earlier draft of this edit, which tried appending display_entry via a second,
@@ -102,7 +102,19 @@
matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that matching its recipe's own necroNecromancyAdept tag in recipes.xml (see that
file's comment - both express the same "available immediately" intent). --> file's comment - both express the same "available immediately" intent). -->
<unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid" unlock_tier="1"/> <unlock_entry item="thrownStoneSpirit,necroWpnBladeNecroKnife,thrownStonePortalBlue,necroHeresyPyramid" unlock_tier="1"/>
<unlock_entry item="braceletSpatialVault" unlock_tier="2"/> <!-- resourceBloodSphere ПЕРЕЕХАЛА СЮДА ИЗ tier 1, 2026-09-16.
Сначала (15.09) она стояла на tier 1 по указанию «доступна на первом грейде»,
то есть с уровня 1. Но вставляется она в Пространственный браслет, а тот
открывается на tier 2 (уровень 20) - ровно этой же строкой. Девятнадцать
уровней игрок мог крафтить расходник к предмету, которого у него нет.
По записанному правилу мода «пороги - по нужде, а не по силе» порог ставится
туда, где предмет впервые нужен, - здесь это момент появления браслета.
Парная правка в recipes.xml: рецепту добавлены теги learnable и
necroNecromancyLvl20. Оба уровня ОБЯЗАНЫ совпадать: RecipeTagUnlocked для
necroNecromancyLvl20 стоит на level="20,5000" ниже, а tier 2 в
unlock_level="1,20,30,60,100,300" - это те же 20. Разойдись они, замок на
панели скилла разошёлся бы с реальной доступностью рецепта. -->
<unlock_entry item="braceletSpatialVault,resourceBloodSphere" unlock_tier="2"/>
<!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано: <!-- ЧЕТЫРЕ МОДА НОЖА ПЕРЕЕХАЛИ СЮДА ИЗ ГРУППЫ 2, 2026-09-09. Продиктовано:
"Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби. "Питьё важно в тот же день. Оно должно быть доступно после 30 убитых зомби.
Еда - 60. Это самые важные для начала выживания модификации. Модификация на Еда - 60. Это самые важные для начала выживания модификации. Модификация на
@@ -159,7 +171,18 @@
<unlock_entry item="necroModKnifeDeadStorm" unlock_tier="3"/> <unlock_entry item="necroModKnifeDeadStorm" unlock_tier="3"/>
</display_entry> </display_entry>
<display_entry icon="SummonZombieDog" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="2000"> <display_entry icon="SummonZombieDog" name_key="craftingNecroNecromancyTier3Name" has_quality="false" unlock_level="2000">
<unlock_entry item="bookSummonZombieDog,bookSummonInsectSwarm,bookSummonZombieGriffin" unlock_tier="1"/> <!-- resourceBloodStone добавлен 2026-09-16 (указание «доступен на третьем грейде
некромантии, в самом его начале»). Тир 3 - это и есть третий грейд, группа
«Ученик», а его единственный unlock_level равен 2000, то есть unlock_tier="1"
здесь и означает «в самом начале грейда, без своего смещения».
ЭТА СТРОКА - ТОЛЬКО ОТОБРАЖЕНИЕ. Открытие рецепта делает тег
necroNecromancyApprentice в recipes.xml вместе с RecipeTagUnlocked
level="2000,5000" ниже; display_entry о тегах не знает и рисует замок сам по
себе, по unlock_tier. Поэтому 2000 здесь и 2000 там ОБЯЗАНЫ совпадать -
иначе замок на панели разойдётся с реальной доступностью рецепта. Совпадают:
один порог, одно значение. -->
<unlock_entry item="bookSummonZombieDog,bookSummonInsectSwarm,bookSummonZombieGriffin,resourceBloodStone" unlock_tier="1"/>
</display_entry> </display_entry>
<display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="3000"> <display_entry icon="SummonZombieBear" name_key="craftingNecroNecromancyTier4Name" has_quality="false" unlock_level="3000">
<unlock_entry item="bookSummonZombieBear,bookSummonZombieWolf,bookBanshee" unlock_tier="1"/> <unlock_entry item="bookSummonZombieBear,bookSummonZombieWolf,bookBanshee" unlock_tier="1"/>
+75
View File
@@ -376,4 +376,79 @@
<ingredient name="resourceYuccaFibers" count="10"/> <ingredient name="resourceYuccaFibers" count="10"/>
</recipe> </recipe>
</append> </append>
<!-- Кровавая сфера, продиктовано 2026-09-15: «Станки не нужны. Ингридиенты: Кровь некроманта,
5 праха зомби. По одному рецепту изготавливается две сферы.»
craft_area нет - крафт личный, как у Камня духов и самого ножа: сфера дешевле призывов и
верстака не требует.
ПОРОГ ПЕРЕНЕСЁН С УРОВНЯ 1 НА 20, 2026-09-16. Изначально (15.09) рецепт стоял вовсе без
тега разблокировки по указанию «доступна на первом грейде» - а рецепт без тега доступен
всегда. Но сфера вставляется в Пространственный браслет, который открывается только на 20
убийствах (necroNecromancyLvl20), то есть девятнадцать уровней её можно было крафтить в
пустоту. Теперь порог общий с браслетом, по записанному правилу мода «пороги - по нужде, а
не по силе».
Тег learnable добавлен вместе с necroNecromancyLvl20 - как у всех остальных гейтованных
рецептов мода, чтобы рецепт не светился в меню до открытия. Парная правка в
progression.xml: сфера переехала в unlock_entry с unlock_tier="2", там же, где браслет.
Цена реальная, а не по списку: одна кровь некроманта стоит ещё и 90% текущего ХП на её
собственный крафт (NecromancerBloodPatch.cs). Две сферы за один заход это и учитывают. -->
<append xpath="/recipes">
<recipe name="resourceBloodSphere" count="2" tags="learnable,packMuleCrafting,necroNecromancyLvl20">
<ingredient name="resourceNecromancerBlood" count="1"/>
<ingredient name="resourceZombieAsh" count="5"/>
</recipe>
</append>
<!-- Кровавый камень, продиктовано 2026-09-15: «Рецепт: 50 праха зомби, 20 костей, 4 флакона
обычной крови, 1 кровь некроманта. Рецепт будет доступен на третьем грейде некромантии
(в самом его начале)... Рабочее место - химическая станция.»
Все четыре имени проверены по файлам, а не по памяти:
resourceZombieAsh - свой предмет мода (Config/items.xml)
resourceBone - ванильный (Data/Config/items.xml:23781)
medicalBloodBag - ванильный (Data/Config/items.xml:19196)
resourceNecromancerBlood - свой предмет мода (Config/items.xml:1321). После отката
15.09 он снова обычный <item> в items.xml, а не модификация.
ЦЕНА ВЫШЕ, ЧЕМ ЧИТАЕТСЯ ПО СПИСКУ: одна кровь некроманта стоит ещё и 90% текущего ХП на
её собственный крафт (NecromancerBloodPatch.cs).
«ТРЕТИЙ ГРЕЙД, В САМОМ ЕГО НАЧАЛЕ» = ровно порог группы 3 «Ученик», без собственного
смещения, то есть тег necroNecromancyApprentice и уровень 2000 (progression.xml,
RecipeTagUnlocked level="2000,5000"). Тег уже заведён и уже работает - свой
necroNecromancyLvl*-тег здесь не нужен, такие одноразовые уровни в моде заведены ровно
один раз (necroNecromancyLvl20 для браслета) и считаются исключением, а не приёмом.
В progression.xml камень добавлен в display_entry третьего тира (unlock_level="2000")
строкой скилл-панели - это чисто отображение, механику открытия делает тег.
ДВА ИМЕНИ, КОТОРЫЕ ЛЕГКО ПЕРЕПУТАТЬ, выписаны проверенными по ванили (образцы ammoGasCan,
carBattery в Data/Config/recipes.xml): craft_area="chemistryStation" - ПОЛНОЕ слово, а
тег - chemStationCrafting, СОКРАЩЁННОЕ "chem", не "chemistry".
Тег learnable - как у остальных гейтованных рецептов мода, чтобы рецепт не светился в
меню до открытия группы.
ПОБОЧНОЕ СЛЕДСТВИЕ, замеченное заранее: химстанция добавляет камню ВТОРОЙ порог поверх
уровня 2000 - она открывается своим путём (перк/схема), с Некромантией не связанным.
Практически к 2000 уровню она у игрока почти наверняка есть, но формально «третий грейд»
перестаёт быть единственным условием. Ручка одна: убрать craft_area и вернуть личный
крафт.
БАЛАНСОВАЯ ЗАМЕТКА: слот у браслета один, значит камень с бесконечной прочностью не
дополняет Кровавую сферу, а ВЫТЕСНЯЕТ её в тот момент, когда игрок его получает. Судя по
формулировке («тот же флакон, но с бесконечной прочностью») это и задумано - камень это
эндгейм-версия расходника, уровень 2000 как раз про это. Записано, чтобы потом не
удивляться, что сфера перестала расходоваться. -->
<append xpath="/recipes">
<recipe name="resourceBloodStone" count="1" craft_area="chemistryStation" tags="learnable,chemStationCrafting,necroNecromancyApprentice">
<ingredient name="resourceZombieAsh" count="50"/>
<ingredient name="resourceBone" count="20"/>
<ingredient name="medicalBloodBag" count="4"/>
<ingredient name="resourceNecromancerBlood" count="1"/>
</recipe>
</append>
</config> </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);
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Drains the colour out of the world while the necromancer is channelling something, and
/// lets it back in when he stops (user request 2026-09-14: "эффект, когда мир становится
/// тёмным и чёрнобелым... повесить его на момент ожидания применения порталов и на момент
/// ожидания утаскивания блока", with "желательно плавно... секунды за 3" in both directions).
/// Shared by both channels so the look, the timing and the name of the effect live in one
/// place rather than drifting apart in two files.
///
/// IT IS THE GAME'S OWN POST-PROCESS, not a reimplementation. EntityPlayerLocal carries a
/// ScreenEffects component - ScreenEffectManager - whose SetScreenEffect(name, intensity,
/// fadeTime) is what the engine itself calls for dying ("Dying"), for spawning in
/// ("VibrantDeSat") and for every buff in the game that tints the screen. THE FADE IS THAT
/// THIRD ARGUMENT: three seconds in and three seconds out cost nothing to implement, because
/// the ramp is the effect system's own.
///
/// THE EFFECT IS "Greyscale", AND THE CHOICE IS ABOUT WHO ELSE TOUCHES IT. These effects are
/// a flat namespace of materials loaded from Resources/ScreenEffects - anyone writing to a
/// name overwrites whatever was there, so picking one is mostly picking a fight to avoid:
///
/// - "Greyscale" is written by exactly two things in the whole game, twitch_buffMonochrome
/// and sandbox_blackandwhite - a Twitch-integration reward and a game-mode toggle. Neither
/// happens in an ordinary session, so the channel owns it in practice.
/// - "Dying" and "Dead" are the death visuals the user was describing, and they are exactly
/// the ones NOT to borrow: EntityPlayerLocal.Update writes "Dying" from the player's own
/// health every time it changes, so any damage mid-channel would take the effect over -
/// and being hit mid-channel is a thing that happens.
/// - "Dark" would have supplied the darkening half. It belongs to buffCrouching, which
/// fires on every crouch with a 0.2s fade and would stamp on this one.
///
/// SO THE DARKENING HALF IS DELIBERATELY NOT DONE. Both effects that dim the screen are owned
/// by something that fights for them - crouching, and dying - and losing that fight looks like
/// a bug in this mod rather than in the effect system. Greyscale alone reads as the world
/// going wrong, which is what was actually asked for; if it wants to be darker too, the list
/// below takes a second entry and nothing else changes.
///
/// NOTHING HERE TOUCHES INPUT. The effect is a camera post-process and outlives the timer
/// window on purpose: the three-second fade back keeps running while the player walks away,
/// which is the point of asking for a fade rather than a switch.
/// </summary>
public static class ChannelVision
{
/// <summary>Seconds to fade in, and to fade back out.</summary>
public const float FadeSeconds = 3f;
/// <summary>What to fade, and how far. A list rather than a single name so a second layer
/// is one entry and not a rewrite - see the class comment on the darkening half.</summary>
public static readonly string[] EffectNames = { "Greyscale" };
/// <summary>Full strength per effect, in the same order as EffectNames.</summary>
public static readonly float[] EffectIntensities = { 1f };
/// <summary>Colour drains out over FadeSeconds.</summary>
public static void Begin(EntityPlayerLocal _player)
{
Apply(_player, _fullStrength: true);
}
/// <summary>Colour comes back over FadeSeconds. Safe to call when nothing is running -
/// fading an effect that is already at zero to zero does nothing - which is what lets
/// every exit path call it without first working out whether it is the one that has to.
/// </summary>
public static void End(EntityPlayerLocal _player)
{
Apply(_player, _fullStrength: false);
}
public static void Apply(EntityPlayerLocal _player, bool _fullStrength)
{
if (_player == null || _player.ScreenEffectManager == null)
{
return;
}
for (int i = 0; i < EffectNames.Length; i++)
{
float intensity = _fullStrength ? EffectIntensities[i] : 0f;
_player.ScreenEffectManager.SetScreenEffect(EffectNames[i], intensity, FadeSeconds);
}
}
}
}
+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) один раз на всю сцену и больше её не /// ПАУЗА. Begin ставит GameManager.Instance.Pause(true) один раз на всю сцену и больше её не
/// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а /// трогает: снимать паузу незачем, потому что любой выход отсюда ведёт в главное меню, а
/// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в /// GameManager.Disconnect() зовёт Pause(false) внутри себя (см. комментарий в
/// PortalStonePatch.ActivateBlackPortal). Как и вся остальная UI-часть этого мода, сцена /// FinishEnding). Как и вся остальная UI-часть этого мода, сцена
/// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это /// рассчитана на локального игрока - Pause вообще работает только в одиночной игре, это
/// ограничение самой ванили, а не мода. /// ограничение самой ванили, а не мода.
/// </summary> /// </summary>
@@ -258,14 +258,12 @@ namespace NecromancerTome
/// "Конец". /// "Конец".
/// ///
/// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы /// ВИДЕО ОТСЮДА УБРАНО 2026-09-09 по прямому указанию ("временно, убираем вообще видосы
/// из финала"). Сам вызов XUiC_VideoPlayer.PlayVideo целиком сохранён в /// из финала"). Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm тогда же
/// PortalStonePatch.PlayBlackPortalVideoLegacy - вернуть видео можно, не восстанавливая /// удалены из мода перед публикацией: все три были побайтовой копией ванильного
/// код по кускам.
///
/// Файлы Video/FinalStay.webm, FinalReturn.webm и BlackPortal.webm УДАЛЕНЫ ИЗ МОДА
/// 2026-09-09 перед публикацией: все три были побайтовой копией ванильного
/// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя. /// TFP_Intro.webm (заглушка для тестов), а раздавать чужой ассет игры в релизе нельзя.
/// Настоящее видео класть под тем же именем. /// Мёртвый код проигрывания видео (PortalStonePatch.PlayBlackPortalVideoLegacy и
/// константа с путём) убран 2026-09-10 - живой пример того же вызова, если видео
/// понадобится вернуть, остался в NoteFlashbackPatch.cs.
/// ///
/// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем, /// Задержки и наезда здесь нет намеренно: смотреть на чёрный экран пять секунд незачем,
/// текст показывается сразу.</summary> /// текст показывается сразу.</summary>
+179
View File
@@ -0,0 +1,179 @@
using System.Collections.Generic;
using UnityEngine.Scripting;
namespace NecromancerTome
{
/// <summary>
/// `necroghost [percent|reset]` - turns the traders' transparency live, without a rebuild
/// (user request 2026-09-14, right after the alpha went from 1% to 10%: "Сделай консольную
/// команду на альфу, чтобы крутить в игре"). The number being hunted - "a ghost, not a broken
/// model" - can only be judged by looking at him, and every step of that hunt otherwise costs
/// an edit, a `dotnet build`, a restart and the four-minute walk back to a trader, because
/// traders are streamed in on approach. This collapses the loop to one line in the console.
///
/// IT TAKES PERCENT OF TRANSPARENCY, NOT ALPHA, and that is deliberate: percent is the unit
/// the request has been made in twice ("буквально 1%", "пусть будет 10%"), while alpha is the
/// unit the renderer wants, and they run in opposite directions - 10% transparent is alpha
/// 0.9. Guessing which one a typed "10" meant would be a coin flip, so the command fixes the
/// unit and prints both back every time.
///
/// NOTHING IS PERSISTED. The value lives for the session; the one that turns out to be right
/// gets written into GhostTraderPatch.DefaultGhostAlpha, which is the line a release ships.
/// A settings file would just be a second place for the answer to hide.
///
/// WHY THE GAME FINDS THIS CLASS WITHOUT ANY REGISTRATION. SdtdConsole.RegisterCommands goes
/// through ReflectionHelpers.FindTypesImplementingBase(typeof(IConsoleCommand)), and that
/// walks ModManager.GetLoadedAssemblies() alongside the game's own - so a ConsoleCmdAbstract
/// in a mod DLL is picked up like any vanilla one. Ordering is not a gamble either:
/// GameManager calls ModManager.LoadMods() three lines before RegisterCommands().
///
/// IsExecuteOnClient IS true BECAUSE THIS CHANGES PIXELS. On a server the command would
/// otherwise run where there is nothing to look at; the flag makes the server bounce it back
/// to the client that typed it (ConnectionManager.ServerConsoleCommand), which is where the
/// materials and the eyes are. In single player it changes nothing.
/// </summary>
[Preserve]
public class ConsoleCmdNecroGhost : ConsoleCmdAbstract
{
public override bool IsExecuteOnClient => true;
public override bool AllowedInMainMenu => false;
public override string[] getCommands()
{
return new string[] { "necroghost", "necrotrader" };
}
public override string getDescription()
{
return "Necromancer's Tome: how transparent the ghost traders are, in percent.";
}
public override string getHelp()
{
return "necroghost - show the current value and mode\n" +
"necroghost <0-100> - set transparency in percent (10 = the default, barely there;\n" +
" 30 = clearly a ghost; past ~30 he stops reading as a body)\n" +
"necroghost blend - fade the body by blending (re-shades it; smooth)\n" +
"necroghost dither - fade the body by dithering (the game's own _Fade; grainy)\n" +
"necroghost reset - back to the built-in default value and mode\n" +
"\n" +
"Applies to traders already in the world, immediately - walk up to one first and\n" +
"watch him while you type. Not saved: tell the mod author what you settled on.\n" +
"\n" +
"THE MODES ARE NOT DEGREES OF ONE THING. The body's own shader cannot blend, so the\n" +
"game fades it by throwing pixels away in a pattern - that is the fine grid. Blend\n" +
"re-shades the body onto the hair's shader, which has a transparent pass, at the\n" +
"cost of the character shader's own lighting. The hair fades the same way either\n" +
"way, so it is the body you compare.\n" +
"\n" +
"If he comes apart instead of fading - teeth through the cheek, an arm through the\n" +
"chest - that is not this number, that is depth writing, and no value here will fix it.";
}
public override void Execute(List<string> _params, CommandSenderInfo _senderInfo)
{
if (_params.Count == 0)
{
Report("Ghost traders");
return;
}
string argument = _params[0].Trim();
if (argument.EqualsCaseInsensitive("reset"))
{
GhostTraderPatch.GhostAlpha = GhostTraderPatch.DefaultGhostAlpha;
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Reset");
return;
}
if (argument.EqualsCaseInsensitive("blend"))
{
SetMode(GhostTraderPatch.BodyOpacityMode.Blend, "Body mode");
return;
}
if (argument.EqualsCaseInsensitive("dither"))
{
SetMode(GhostTraderPatch.BodyOpacityMode.Dither, "Body mode");
return;
}
if (!TryParsePercent(argument, out float percent))
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
"necroghost: '" + argument + "' is neither a percentage nor blend/dither/reset. " +
"Try 'necroghost 10', or 'help necroghost'.");
return;
}
if (percent < 0f || percent > 100f)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
"necroghost: " + percent.ToString("0.#") + "% is outside 0-100. 0 = solid, 100 = invisible.");
return;
}
GhostTraderPatch.GhostAlpha = 1f - percent / 100f;
Report("Set");
// Said only when asked for, and only once the value is actually past the point where
// the two failure modes stop looking different - see GhostTraderPatch.GhostAlpha.
if (GhostTraderPatch.GhostAlpha < 0.7f)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
" (past ~30% the silhouette stops reading as a solid body at all, which looks like " +
"a broken model for a different reason than depth writing does)");
}
}
/// <summary>Switches how the body is faded and rebuilds the traders already standing, which
/// is the expensive path - the materials have to be built again from the originals, since
/// a re-shaded material cannot be un-re-shaded. Changing only the number never comes here.
/// Saying so out loud matters: this is the one thing in the command that is not free, and
/// flipping modes back and forth while hunting a value is the obvious way to use it.</summary>
public static void SetMode(GhostTraderPatch.BodyOpacityMode _mode, string _prefix)
{
bool changed = GhostTraderPatch.BodyMode != _mode;
GhostTraderPatch.BodyMode = _mode;
int rebuilt = changed ? GhostTraderPatch.Reapply() : 0;
Report(_prefix);
if (changed && rebuilt > 0)
{
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
" (" + rebuilt + " renderer(s) rebuilt from their original materials)");
}
}
/// <summary>Current value plus what it actually reached, in both units, and which way the
/// body is being faded. The count is the half that answers "did it do anything": 0
/// materials means no trader has been converted yet - they stream in on approach - not
/// that the number was refused.
///
/// The trader count is "held as a ghost RIGHT NOW", not "seen this session": since the
/// 2026-09-15 fix, Ghosted is keyed by entity id but re-entered when a trader is rebuilt,
/// and a trader whose chunk has unloaded keeps his entry only until the next sweep finds
/// his model gone. So the number falls as well as rises, and that is correct.</summary>
public static void Report(string _prefix)
{
float alpha = GhostTraderPatch.GhostAlpha;
int applied = GhostTraderPatch.Retint();
SingletonMonoBehaviour<SdtdConsole>.Instance.Output(
_prefix + ": " + ((1f - alpha) * 100f).ToString("0.#") + "% transparent (alpha " +
alpha.ToString("0.###") + "), body mode " + GhostTraderPatch.BodyMode +
", applied to " + applied + " live material(s) across " +
GhostTraderPatch.Ghosted.Count + " trader(s) currently held as ghosts.");
}
/// <summary>Percent out of what the user typed. StringParsers is the game's own parser and
/// is culture-independent, which matters here - but it reads ',' as a THOUSANDS separator,
/// so on a keyboard where the decimal key produces a comma "12,5" would silently parse as
/// 125 and the trader would vanish. The comma is turned into a point before it gets there.
/// A trailing '%' is accepted because it is the obvious thing to type.</summary>
public static bool TryParsePercent(string _argument, out float _percent)
{
string text = _argument.Replace(',', '.').TrimEnd('%').Trim();
return StringParsers.TryParseFloat(text, out _percent);
}
}
}
+998
View File
@@ -0,0 +1,998 @@
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, THEN RAISED TO 10%. The user
/// first said "убираем совсем", confirmed the result ("торговец стал непрозрачным и
/// полностью чёрно-белым, как и требовалось"), then asked for "лёгкую прозрачность,
/// буквально 1%", and on 2026-09-14 for 10% - "чтобы он выглядел как призрак, а не как
/// сломанная модель". Dropping it was still the release this effect needed, because it
/// is what allowed the shader to stay put - see below; the alpha is 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.
///
/// THE ALPHA IS TURNED FROM THE CONSOLE, not from this file: `necroghost <percent>`, in
/// GhostTraderCommand.cs. A rendering balance can only be judged by looking at it, and a
/// rebuild-restart-walk-to-a-trader cycle per step is not a way to look at anything.
///
/// 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.
///
/// The same tick is what puts a trader BACK once the game has rebuilt him - see Ghosted, and
/// the bug of 2026-09-15 that taught this file the difference between an entity id and a
/// model. A spawn hook would not have helped there either: the entity was never re-created as
/// far as its id is concerned.
/// </summary>
public static class GhostTraderPatch
{
/// <summary>Seconds between sweeps.</summary>
public const float SweepInterval = 2f;
/// <summary>What a fresh game boots with. 1 = solid; 0.3 is what the hunt actually landed
/// on - the user set 70% transparency in game on 2026-09-14 once the body was blending
/// instead of dithering, and kept it. THE VALUE FOUND IN GAME BELONGS HERE: the console
/// command turns GhostAlpha for the session only and deliberately persists nothing, so a
/// number worth keeping has to be written into this line.
///
/// The road here was 1% -> 10% -> 70%, and the last jump was not a change of taste: at 10%
/// the body was still being dithered, and a dither is only bearable while it is faint. Once
/// it became real blending, far more of it turned out to look right.</summary>
public const float DefaultGhostAlpha = 0.3f;
/// <summary>The opacity actually in use. THIS IS THE ONE NUMBER TO TURN when hunting the
/// balance between "ghost" and "broken model" - everything else in this file is about
/// making the number mean what it says - and `necroghost &lt;percent&gt;` turns it live
/// (GhostTraderCommand.cs), because every step of that hunt otherwise costs a rebuild, a
/// restart and the four-minute walk to a trader.
///
/// Which way to turn it is decided by WHICH failure you are looking at, and the two look
/// nothing alike:
/// - reads as a solid person, no ghost at all -> lower it (0.85, 0.8).
/// - the world shows through him but he still reads as one body -> this is the target.
/// - you can see his teeth through his cheek, eyes through eyelids, an arm through the
/// chest -> that is the "broken model", and it is NOT this number's fault. It means
/// depth writing came off somewhere; see _ZWrite in ApplyTransparency. Dropping the
/// alpha further only makes it worse.
/// THE OLD "nothing below ~0.7" NOTE WAS WRONG, and it is worth saying why rather than
/// quietly deleting: it was written while the body was still dithering, where a low value
/// means a coarse pattern and the silhouette falls apart early. With the body blending,
/// 0.3 reads as a ghost and holds together - the limit belonged to the technique, not to
/// the eye. Depth writing is what keeps him one body, and it does not care how low the
/// number goes.</summary>
public static float GhostAlpha = DefaultGhostAlpha;
/// <summary>One opacity lever this patch installed on a trader, remembered so the alpha
/// can be turned again WITHOUT rebuilding anything. Keeping the base value is the whole
/// point: the live value is always base * GhostAlpha, never "multiply what is there now by
/// the new number" - that would compound (0.9 then 0.8 would land on 0.72) and the second
/// turn of the dial would lie about where it put you.
///
/// IsColour says which of the two kinds this is - the alpha channel of a colour, or a
/// plain float - because a trader needs BOTH and they are not interchangeable: the body
/// only has the float (_Fade) and the hair only has the colour (_Color). One material can
/// therefore contribute one entry, and one trader contributes several of both kinds.</summary>
public struct GhostMaterial
{
public Material Material;
public string Property;
public bool IsColour;
public float BaseValue;
}
/// <summary>A renderer this patch has taken over, with the materials it had before. The
/// originals are the reason a mode can be switched at all: after the first pass
/// renderer.sharedMaterials hands back OUR clones, so rebuilding from what is currently
/// on the renderer would re-shade an already re-shaded material and there would be no way
/// back to the body's real one. Rebuilding always starts here instead.</summary>
public struct GhostRenderer
{
public Renderer Renderer;
public Material[] Originals;
}
/// <summary>What one trader was actually given, kept so the sweep can ask "is he STILL a
/// ghost" instead of only "have I seen this id". The renderers are the answer: a trader
/// that streams out and back in is rebuilt from scratch - new GameObject, new renderers,
/// the game's own materials - while keeping the id he was saved under, so an id on its own
/// says nothing about the model standing there now. See the Ghosted comment.</summary>
public struct GhostBody
{
public Renderer[] Renderers;
}
/// <summary>Every renderer taken over, in the order it was found. Pruned of destroyed
/// renderers as they are walked; dropped wholesale when the world unloads.</summary>
public static readonly List<GhostRenderer> Converted = new List<GhostRenderer>();
/// <summary>Every material installed on a live trader that has somewhere to put an alpha.
/// Retint walks this instead of redoing the work: re-running the sweep would re-clone the
/// materials and, far worse, hand the 4096x4096 desaturation another readback per trader.
/// Entries go stale when the game destroys the material with its trader - Retint prunes
/// those, and WorldShuttingDown drops the lot.</summary>
public static readonly List<GhostMaterial> TintedMaterials = new List<GhostMaterial>();
/// <summary>Colour properties that might carry an alpha, best first. Confirmed in game:
/// the hair/beard shader "Game/Autodesk" has _Color; the BODY shader "Game/Character" has
/// no colour property at all - see FadeNameHints.</summary>
public static readonly string[] TintNameHints = { "_Color", "_BaseColor", "_TintColor", "_Tint" };
/// <summary>How the BODY is made see-through. The two are not two settings of one thing,
/// they are two different renderers' worth of behaviour, and only a look in game can pick
/// between them - which is why both stay reachable from the console.
///
/// Dither is the game's own _Fade, and on 2026-09-14 the user reported what it actually
/// looks like: "тело гаснет, но идёт мелкой сеточкой". That is screen-door transparency -
/// the shader is not blending anything, it is THROWING PIXELS AWAY in a fixed pattern.
/// It is what an opaque-queue character shader can do without a transparent pass, and no
/// value of GhostAlpha turns a dropped pixel into a translucent one.
///
/// Blend re-shades the body onto the shader the HAIR is already drawn with, which does
/// have a transparent pass. The existence proof is on the same model in the same frame:
/// the beard has been fading smoothly this whole time while the body was dithering.</summary>
public enum BodyOpacityMode
{
Dither,
Blend
}
/// <summary>Blend by default: dither has been looked at and rejected. `necroghost blend`
/// and `necroghost dither` switch it live - see GhostTraderCommand.cs.</summary>
public static BodyOpacityMode BodyMode = BodyOpacityMode.Blend;
/// <summary>The hair's shader, kept once it is found on a real trader. Taken off the model
/// rather than through Shader.Find so it is the exact shader already proven to work in
/// this scene - name lookups can miss a stripped or renamed shader and hand back null,
/// and the mod has been down that road twice already with the Unlit family.</summary>
public static Shader BlendShader;
/// <summary>Normal-map properties, best first. "Game/Character" calls it _Normal, the hair
/// shader wants _BumpMap - the same disagreement the albedo has.</summary>
public static readonly string[] NormalNameHints = { "_BumpMap", "_Normal", "_NormalMap", "_NormalTex" };
/// <summary>Float properties that fade the whole material out, best first. This is the
/// body's only lever and the game's own: EntityModel.SetFade writes exactly "_Fade", and
/// guards it with `material.HasProperty("_Fade") && shader.name.Contains("Game/Character")`
/// - the same shader our traders' bodies are drawn with. EntityAlive.Update drives it from
/// renderFade, and SetVisible(renderFade > 0.01f) right next to it settles the direction
/// beyond doubt: 1 = solid, 0 = gone, exactly like an alpha.</summary>
public static readonly string[] FadeNameHints = { "_Fade" };
/// <summary>Traders already converted, by entity id, WITH the renderers each was given.
/// Cleared when the world unloads.
///
/// THE VALUE IS NOT DECORATION - it is the fix for "the trader stopped being a ghost the
/// next morning" (2026-09-15). This was a HashSet of ids, and an id is not enough:
/// EntityFactory restores `entity.entityId = ecd.id` from the save, so a trader who is
/// streamed out while the player is away (they are streamed IN on approach in the first
/// place - see the class comment) comes back as a BRAND NEW GameObject carrying the SAME
/// id, with the game's own materials on it. The set still held the id, the sweep skipped
/// him, and he stayed an ordinary living person for the rest of the session.
///
/// It is NOT the restock, which was the first guess and is worth writing down as ruled
/// out: TraderData's reset rewrites PrimaryInventory and lastInventoryUpdate and touches
/// no renderer, and TraderArea.SetClosed - the whole open/close cycle - only works doors,
/// lights and speakers. Nothing on the shop's clock ever reaches the model. What does is
/// the chunk the shop sits in, which is why the symptom looks like it follows the morning:
/// the player is away for the night, the trader unloads with his chunk, and he is rebuilt
/// when they walk back.
///
/// Holding the renderers makes the question answerable: Unity's destroyed objects compare
/// equal to null, so a trader whose model is gone is visible as such, and the same check
/// covers any other rebuild of the model for free.</summary>
public static readonly Dictionary<int, GhostBody> Ghosted = new Dictionary<int, GhostBody>();
/// <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();
TintedMaterials.Clear();
Converted.Clear();
BlendShader = null;
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.TryGetValue(trader.entityId, out GhostBody body))
{
if (IsIntact(body))
{
continue;
}
// His model was destroyed and rebuilt under him. Drop what is known about the
// old one before building the new, or Converted and TintedMaterials keep
// entries for renderers and materials that no longer exist.
Debug.Log("[NecromancerTome] GhostTraderPatch: entity " + trader.entityId +
" came back with a new model - ghosting him again");
Ghosted.Remove(trader.entityId);
Prune();
}
if (ApplyGreyscale(trader, out GhostBody fresh))
{
Ghosted[trader.entityId] = fresh;
}
}
}
/// <summary>Is this trader still wearing what we put on him? False the moment any part of
/// the model we converted has been destroyed - which is what a stream-out and back in
/// looks like from here, and equally what any other rebuild of the model would look like.
///
/// Deliberately NOT "does he have renderers we have not converted": a trader gains and
/// loses renderers in normal play (a held item, worn equipment), and treating that as a
/// rebuild would re-run the conversion on renderers already carrying our materials - whose
/// sharedMaterials hand back OUR clones, so the "originals" kept for the next mode switch
/// would be re-shaded ones with no way back. The known gap that leaves is a part of the
/// model built AFTER the first sweep reached him: it stays in colour until he next
/// reloads. Nothing like that has been seen on the six traders.</summary>
public static bool IsIntact(GhostBody _body)
{
if (_body.Renderers == null || _body.Renderers.Length == 0)
{
return false;
}
foreach (Renderer renderer in _body.Renderers)
{
if (renderer == null)
{
return false;
}
}
return true;
}
/// <summary>Drops every entry whose Unity object the game has destroyed. Both lists are
/// session-long and keyed by nothing - without this they grow by one trader's worth of
/// renderers and materials every time a trader is rebuilt, and Retint/Reapply would be
/// walking the wreckage. A material assigned through renderer.materials is owned by that
/// renderer and dies with it, so one pass settles both.</summary>
public static void Prune()
{
for (int i = Converted.Count - 1; i >= 0; i--)
{
if (Converted[i].Renderer == null)
{
Converted.RemoveAt(i);
}
}
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
{
if (TintedMaterials[i].Material == null)
{
TintedMaterials.RemoveAt(i);
}
}
}
/// <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. On true, _body carries
/// the renderers taken over, which is how the next sweep tells this trader from a rebuilt
/// one standing under the same entity id.</summary>
public static bool ApplyGreyscale(EntityTrader _trader, out GhostBody _body)
{
_body = default(GhostBody);
Renderer[] renderers = _trader.GetComponentsInChildren<Renderer>(true);
if (renderers == null || renderers.Length == 0)
{
return false;
}
// Before anything is touched, because the body's replacement shader is found on the
// trader's OWN hair and the hair is not guaranteed to come first in this array.
FindBlendShader(renderers);
int converted = 0;
int leversBefore = TintedMaterials.Count;
List<Renderer> taken = new List<Renderer>(renderers.Length);
foreach (Renderer renderer in renderers)
{
if (renderer == null || renderer is ParticleSystemRenderer)
{
continue;
}
Material[] sources = renderer.sharedMaterials;
if (sources == null || sources.Length == 0)
{
continue;
}
Converted.Add(new GhostRenderer { Renderer = renderer, Originals = sources });
taken.Add(renderer);
if (Convert(renderer, sources))
{
converted++;
}
}
_body.Renderers = taken.ToArray();
// The lever count is the half that answers "will the console command reach him":
// desaturation and opacity come from different properties, and the body had the first
// without the second until 2026-09-14. Fewer levers than converted renderers means
// some part of this trader can only ever be black-and-white, never transparent.
Debug.Log("[NecromancerTome] GhostTraderPatch: " + _trader.EntityClass.entityClassName +
" (entity " + _trader.entityId + ") - " + converted + " of " + renderers.Length +
" renderer(s) desaturated, " + (TintedMaterials.Count - leversBefore) + " opacity lever(s) installed");
return true;
}
/// <summary>Builds and installs this renderer's ghost materials from the ORIGINALS it was
/// handed. True when anything was actually changed - a renderer nothing could be done to
/// keeps the materials it has rather than being handed a half-built array.</summary>
public static bool Convert(Renderer _renderer, Material[] _sources)
{
Material[] ghosts = new Material[_sources.Length];
bool anyChanged = false;
for (int i = 0; i < _sources.Length; i++)
{
ghosts[i] = MakeGreyMaterial(_sources[i], ref anyChanged);
}
if (anyChanged)
{
_renderer.materials = ghosts;
}
return anyChanged;
}
/// <summary>Rebuilds every trader already converted, from their original materials, under
/// whatever BodyMode is set now. This is the expensive path and exists only for the mode
/// switch; changing the alpha alone goes through Retint, which touches no materials at
/// all. Even here the 4096x4096 desaturation is not redone - GreyTextures is keyed by the
/// source texture, and the source is the same original every time.</summary>
public static int Reapply()
{
TintedMaterials.Clear();
int rebuilt = 0;
for (int i = Converted.Count - 1; i >= 0; i--)
{
GhostRenderer entry = Converted[i];
if (entry.Renderer == null)
{
Converted.RemoveAt(i);
continue;
}
if (Convert(entry.Renderer, entry.Originals))
{
rebuilt++;
}
}
return rebuilt;
}
/// <summary>Remembers the first shader on this model that can actually blend - it needs
/// both a colour to put an alpha in and a blend mode to honour it. On a trader that is the
/// hair's shader. Falls back to looking the name up only if the model somehow has no such
/// material, and says so either way, because a null here silently disables the whole Blend
/// mode and leaves the body dithering with no explanation.</summary>
public static Shader FindBlendShader(Renderer[] _renderers)
{
if (BlendShader != null)
{
return BlendShader;
}
foreach (Renderer renderer in _renderers)
{
if (renderer == null || renderer is ParticleSystemRenderer)
{
continue;
}
Material[] materials = renderer.sharedMaterials;
if (materials == null)
{
continue;
}
foreach (Material material in materials)
{
if (material == null || material.shader == null || !CanBlendInPlace(material))
{
continue;
}
BlendShader = material.shader;
Debug.Log("[NecromancerTome] GhostTraderPatch: blend shader taken off the model: '" +
BlendShader.name + "'");
return BlendShader;
}
}
BlendShader = Shader.Find("Game/Autodesk");
Debug.Log("[NecromancerTome] GhostTraderPatch: no blend-capable material on this model; " +
"the shader was looked up by name instead and came back " +
(BlendShader != null ? BlendShader.name : "NULL - the body stays dithered"));
return BlendShader;
}
/// <summary>Whether this material can be made see-through where it stands: somewhere to
/// put an alpha, and a blend mode to make the alpha mean something. The body fails both
/// halves and the hair passes both - which is the whole difference between them, and the
/// reason this is a capability test rather than a check on the shader's name.</summary>
public static bool CanBlendInPlace(Material _material)
{
if (!_material.HasProperty("_SrcBlend") || !_material.HasProperty("_DstBlend"))
{
return false;
}
foreach (string hint in TintNameHints)
{
if (_material.HasProperty(hint))
{
return true;
}
}
return false;
}
/// <summary>
/// The body, re-shaded onto the hair's shader so it can blend the way the hair does, with
/// the black-and-white albedo it would have had anyway.
///
/// ONLY WHAT IS UNDERSTOOD IS CARRIED OVER - albedo and normal map. The body's third map,
/// _RMOE, is a packed roughness/metallic/occlusion/emission texture, and the destination
/// shader's _MetallicGlossMap expects metallic in R and smoothness in A: the channels do
/// not line up, and wiring them by hope is exactly how a character comes out looking like
/// wet plastic or a negative. It is left empty and the two floats are set by hand instead
/// - no metal, barely any gloss, which is what a dead man in black and white should be.
///
/// This is a real trade and worth stating plainly: the body loses the character shader's
/// own lighting response and gets standard lighting instead. In exchange it can actually
/// be translucent rather than dithered. If it reads wrong in game, `necroghost dither` is
/// one line away and nothing has to be rebuilt.
/// </summary>
public static Material MakeBlendMaterial(Material _source, Texture2D _albedo)
{
Material blend = new Material(BlendShader);
blend.SetTexture("_MainTex", _albedo);
Texture normal = FindTexture(_source, NormalNameHints);
if (normal != null && blend.HasProperty("_BumpMap"))
{
blend.SetTexture("_BumpMap", normal);
}
MakeMatte(blend);
// Standard's own "Fade" setting. The shader branches on keywords rather than on this
// float, and ApplyTransparency sets those - but a material inspected later with its
// mode still reading "Opaque" is a trap for whoever looks next.
if (blend.HasProperty("_Mode"))
{
blend.SetFloat("_Mode", 2f);
}
ApplyTransparency(blend);
return blend;
}
/// <summary>
/// Takes the shine off (user request 2026-09-14, once the body was blending properly:
/// "слишком оно бликует, я бы вообще сделал матовым"). Applied to EVERY ghost material, not just the
/// re-shaded body: the hair is drawn by the same shader and was keeping its own shine, and
/// a matte body under a glossy beard would have read worse than either.
///
/// THREE THINGS, BECAUSE THEY FAIL DIFFERENTLY:
///
/// - _Glossiness (smoothness) to 0. This one is plain shader maths and always works -
/// the surface becomes maximally rough, and a rough surface has no tight highlight.
/// It is the load-bearing half of this method.
/// - _SpecularHighlights / _GlossyReflections to 0 WITH their keywords. These are the
/// Standard shader's [ToggleOff] pair: the float is only what the inspector shows, the
/// shader branches on _SPECULARHIGHLIGHTS_OFF / _GLOSSYREFLECTIONS_OFF, so setting the
/// float alone does nothing at all. They kill the direct highlight and the reflection
/// probe contribution outright.
/// - The gloss MAPS, cleared if anything is in them. A map wins over the float: leave
/// one in place and _Glossiness is ignored, which is the silent way for all of this to
/// appear to do nothing. They are empty on these traders today - this is for the next
/// model that is not.
///
/// HONEST LIMIT: those two keywords are `shader_feature`, which Unity strips at build time
/// if no material in the game ships with them set. If they were stripped, EnableKeyword is
/// a no-op and only the roughness half lands. That is why the roughness half is not
/// treated as a fallback but as the main lever - and why this is not attempted at all on
/// the body's own "Game/Character" shader, which has none of these properties: its shine
/// lives inside the packed _RMOE texture and would have to be repainted, not switched off.
/// </summary>
public static void MakeMatte(Material _material)
{
if (_material.HasProperty("_Glossiness"))
{
_material.SetFloat("_Glossiness", 0f);
}
if (_material.HasProperty("_GlossMapScale"))
{
_material.SetFloat("_GlossMapScale", 0f);
}
if (_material.HasProperty("_Metallic"))
{
_material.SetFloat("_Metallic", 0f);
}
if (_material.HasProperty("_SpecularHighlights"))
{
_material.SetFloat("_SpecularHighlights", 0f);
_material.EnableKeyword("_SPECULARHIGHLIGHTS_OFF");
}
if (_material.HasProperty("_GlossyReflections"))
{
_material.SetFloat("_GlossyReflections", 0f);
_material.EnableKeyword("_GLOSSYREFLECTIONS_OFF");
}
ClearTexture(_material, "_SpecGlossMap");
ClearTexture(_material, "_MetallicGlossMap");
}
/// <summary>Empties a texture slot if this material has one and it is not already empty.
/// </summary>
public static void ClearTexture(Material _material, string _property)
{
if (_material.HasProperty(_property) && _material.GetTexture(_property) != null)
{
_material.SetTexture(_property, null);
}
}
/// <summary>First texture among these property names that this material actually has
/// something in.</summary>
public static Texture FindTexture(Material _source, string[] _hints)
{
foreach (string hint in _hints)
{
if (_source.HasProperty(hint))
{
Texture texture = _source.GetTexture(hint);
if (texture != null)
{
return texture;
}
}
}
return null;
}
/// <summary>
/// This material's ghost version. Two shapes, and which one it takes is decided by what
/// the material can do rather than by what it is called:
///
/// - CAN be made see-through where it stands (the hair) -> a clone with the SAME shader
/// and only its albedo swapped for a black-and-white copy. Lighting, normal maps,
/// specular, skinning - all still the game's own.
/// - CANNOT (the body: no colour, no blend mode) -> under BodyMode.Blend it is re-shaded
/// onto the shader the hair uses, which can. Under BodyMode.Dither it takes the clone
/// path anyway and gets the _Fade lever, which works but throws pixels away.
///
/// The albedo is desaturated first either way, because both shapes need it and the result
/// is cached per source texture - the re-shaded body is not paying for a second readback.
/// </summary>
public static Material MakeGreyMaterial(Material _source, ref bool _changed)
{
if (_source == null)
{
return null;
}
ProbeShaderOnce(_source);
string albedoProperty = FindAlbedoProperty(_source);
Texture2D desaturated = albedoProperty != null
? Desaturate(_source.GetTexture(albedoProperty))
: null;
// Re-shading without an albedo to hand over would produce an untextured silhouette -
// the exact failure this patch already shipped once, on 2026-09-13. If the texture
// could not be read, the body keeps its own shader and stays dithered instead, and
// the lever count in the log is what says so.
if (BodyMode == BodyOpacityMode.Blend && BlendShader != null && desaturated != null &&
!CanBlendInPlace(_source))
{
_changed = true;
return MakeBlendMaterial(_source, desaturated);
}
Material grey = new Material(_source);
MakeMatte(grey);
if (ApplyTransparency(grey))
{
_changed = true;
}
if (desaturated == null)
{
// Nothing to desaturate on this material; hand back the clone unchanged rather
// than dropping the renderer's material entirely.
return grey;
}
grey.SetTexture(albedoProperty, desaturated);
_changed = true;
return grey;
}
/// <summary>
/// Dials this material's opacity down to GhostAlpha. THREE levers, each conditional,
/// because a trader is drawn by two different shaders that have almost nothing in common -
/// and the first version of this method only had the levers the hair happens to own, which
/// is why the user reported on 2026-09-14 that the console command "управляет только
/// прозрачностью бороды". The probe had already printed the reason, one line each:
///
/// shader 'Game/Character' - albedo: _Albedo -> HD_Rekt 4096x4096;
/// tint property: <none>; blend-mode properties present: False
/// shader 'Game/Autodesk' - albedo: _MainTex -> HD_Rekt_Hair 2048x2048;
/// tint property: _Color (alpha 1); blend-mode properties present: True
///
/// The BODY has no colour property and no blend mode at all. Two of the three levers below
/// simply do not exist on it, and no value of GhostAlpha was ever going to reach it.
///
/// - A COLOUR with an alpha channel (_Color and friends). The hair's lever; the body
/// does not have one.
/// - THE FADE FLOAT (_Fade). The body's lever, and the game's own: EntityModel.SetFade
/// writes this exact property and guards it with a check for the "Game/Character"
/// shader by name, so this is not a property we found and hoped about - it is the one
/// the engine itself fades these very models with. Nothing else in the body's property
/// list can carry an opacity: the rest is _Albedo/_Normal/_RMOE, an alpha-CUTOUT
/// cluster (_AlphaCut, _AlphaCutoff, _SoftAlphaCutoff, _AlphaSoftness - a dissolve,
/// which punches holes rather than making glass), _EmissiveColor, and flags.
/// - THE BLEND MODE (_SrcBlend/_DstBlend). An opaque shader ignores any alpha it is
/// handed, so without this the COLOUR 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. The _Fade
/// lever needs none of it - the shader does its own fading internally, which is
/// exactly why it exists.
///
/// _ZWrite IS LEFT ALONE ON PURPOSE, AND AT 10% THAT IS THE WHOLE BALANCE. The usual
/// transparency recipe switches depth writing off, which is right for glass and wrong for
/// a person: without it every surface of the model blends over every other one, and the
/// trader becomes teeth through cheeks, eyeballs through eyelids, the far arm through the
/// chest - exactly the "сломанная модель" the user does not want. With it on, the depth
/// test keeps only the nearest surface and that one surface blends with the WORLD behind
/// him. So he goes see-through without coming apart: a ghost, not a mess.
///
/// At 99% this cost nothing because there was nothing to see through; at 90% it is the
/// reason the effect is usable at all. Note that it is not set here either way - these
/// shaders write depth by default, and leaving the property untouched is what keeps it.
///
/// 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);
float baseAlpha = tint.a;
tint.a = baseAlpha * GhostAlpha;
_material.SetColor(hint, tint);
TintedMaterials.Add(new GhostMaterial
{
Material = _material,
Property = hint,
IsColour = true,
BaseValue = baseAlpha
});
touched = true;
break;
}
foreach (string hint in FadeNameHints)
{
if (!_material.HasProperty(hint))
{
continue;
}
// A base of 0 would mean the material is already fully faded out, which no
// standing trader is - it means the property is sitting at a default nobody set.
// Multiplying by it would make him vanish outright and no value of GhostAlpha
// could bring him back, so it is read as "solid" instead.
float baseFade = _material.GetFloat(hint);
if (baseFade <= 0f)
{
baseFade = 1f;
}
_material.SetFloat(hint, baseFade * GhostAlpha);
TintedMaterials.Add(new GhostMaterial
{
Material = _material,
Property = hint,
IsColour = false,
BaseValue = baseFade
});
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>Pushes the current GhostAlpha onto every trader already standing in the world,
/// and returns how many materials took it. Traders that spawn later need nothing from this
/// - ApplyTransparency reads the same field on the way past.
///
/// Unity's fake null is the reason for the rebuild-in-place rather than a simple loop: a
/// destroyed Material compares equal to null but is still a live list entry, and touching
/// it throws. Walking backwards and dropping those as we go keeps the list from growing
/// across a session of traders streaming in and out.</summary>
public static int Retint()
{
int applied = 0;
for (int i = TintedMaterials.Count - 1; i >= 0; i--)
{
GhostMaterial entry = TintedMaterials[i];
if (entry.Material == null)
{
TintedMaterials.RemoveAt(i);
continue;
}
if (entry.IsColour)
{
Color tint = entry.Material.GetColor(entry.Property);
tint.a = entry.BaseValue * GhostAlpha;
entry.Material.SetColor(entry.Property, tint);
}
else
{
entry.Material.SetFloat(entry.Property, entry.BaseValue * GhostAlpha);
}
applied++;
}
return applied;
}
/// <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;
}
}
string fade = "<none>";
foreach (string hint in FadeNameHints)
{
if (_source.HasProperty(hint))
{
fade = hint + " (" + _source.GetFloat(hint).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 + "; fade property: " + fade +
"; blend-mode properties present: " + canBlend);
}
}
}
+15
View File
@@ -10,11 +10,26 @@ namespace NecromancerTome
/// </summary> /// </summary>
public class ModEntry : IModApi 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) public void InitMod(Mod _modInstance)
{ {
Instance = _modInstance;
var harmony = new Harmony("necromancertome.harmony"); var harmony = new Harmony("necromancertome.harmony");
harmony.PatchAll(Assembly.GetExecutingAssembly()); harmony.PatchAll(Assembly.GetExecutingAssembly());
PetFollowPatch.Init(); 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 // PyramidWardPatch.cs's TEFeaturePyramidWard needs no Init() call - it's discovered
// automatically by the engine's own TileEntityCompositeData reflection scan (see that // automatically by the engine's own TileEntityCompositeData reflection scan (see that
// file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook. // file's class doc comment), not registered here like PetFollowPatch's UnityUpdate hook.
+87 -2
View File
@@ -5,8 +5,19 @@ namespace NecromancerTome
{ {
/// <summary> /// <summary>
/// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml /// "Кровь некроманта" (Necromancer's Blood) - dictated 2026-08-30. See items.xml
/// (resourceNecromancerBlood) for the item, recipes.xml for the base recipe (an empty jar, /// (resourceNecromancerBlood) for the item and recipes.xml for the base recipe (an empty jar,
/// like any other resource conversion). Two rules the user asked for have NO vanilla XML /// like any other resource conversion).
///
/// IT LIVES IN items.xml, AND THAT IS NOT AN ACCIDENT. On 2026-09-15 it was moved into
/// item_modifiers.xml so it could be installed in the Spatial Bracelet, and that move
/// DESTROYED a character in a save: ItemValue.Read/Write gate the modification block on
/// !(itemClass is ItemClassModifier), so the item's CLASS decides the byte layout of every
/// stack of it in the save, and an existing save read one byte off from the first blood stack
/// onward. It was rolled back the same day, the bracelet's charge became a brand-new item
/// (resourceBloodSphere, and later resourceBloodStone) instead, and the rule stands: an item
/// that could already be in someone's inventory must not change class in either direction.
/// The full account is in BACKLOG.md; the earlier wording of this comment claimed the modifier
/// home as current and outlived the code by a day. Two rules the user asked for have NO vanilla XML
/// equivalent at all, so both are enforced here instead: /// equivalent at all, so both are enforced here instead:
/// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or /// 1. "нужна... наличие любого ножа" - a knife must be present (in the toolbelt or
/// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not /// backpack) to craft this, but is NOT consumed. recipes.xml has no "required but not
@@ -50,6 +61,73 @@ namespace NecromancerTome
public const string BloodItemName = "resourceNecromancerBlood"; public const string BloodItemName = "resourceNecromancerBlood";
public const float HealthCostFraction = 0.9f; public const float HealthCostFraction = 0.9f;
/// <summary>Damage above which the player's BIG pain grunt is used instead of the small
/// one. Not invented: it is vanilla's own threshold, read out of
/// EntityPlayer.GetSoundHurt(DamageSource, int) - the override that every spike and every
/// strand of barbed wire goes through. Its IL is
///
/// ldarg.2 // _damageStrength
/// ldc.i4.s 15
/// bgt.s -> GetSoundHurt() // strictly MORE than 15 -> soundHurt (…painlg)
/// call GetSoundHurtSmall() // 15 or less -> soundHurtSmall (…painsm)
///
/// so the comparison is strictly greater-than, and 15 exactly still counts as small. The
/// same method has an earlier branch for damage type 16 that returns GetSoundDrownPain();
/// that one is the drowning case and has nothing to do with us.</summary>
public const int PainSoundBigDamage = 15;
/// <summary>Plays the player's own pain grunt, picking the big or the small one by the
/// same rule vanilla uses for spikes and barbed wire (user request 2026-09-16: "в игре
/// есть звук боли (когда персонаж напарывается на колья или на колючую проволоку). Пусть
/// этот звук воспроизводится при создании крови некроманта").
///
/// WHY THE SOUND NAME IS ASKED FOR AND NOT SPELLED OUT. The clip differs by gender -
/// playerMale carries SoundHurt="player1painlg"/SoundHurtSmall="player1painsm" and
/// playerFemale overrides both to player2pain* (Data/Config/entityclasses.xml). Hardcoding
/// "player1painlg" would have given every female character a male grunt. GetSoundHurt() and
/// GetSoundHurtSmall() are public on EntityAlive and are plain field reads (verified: each
/// one's whole body is "ldarg.0; ldfld soundHurt|soundHurtSmall; ret"), so they return
/// whatever this entity's own class declared and cost nothing.
///
/// WHY NOT GetSoundHurt(DamageSource, int), which would pick for us: it needs a
/// DamageSource, and this is not damage from a source - the HP here is spent by AddHealth,
/// deliberately (see the comment at the call site). Its selection rule is three lines, so
/// it is reproduced instead of faked with a synthetic DamageSource.
///
/// The null guard is vanilla's too: EntityAlive.OnUpdateEntity stores the result and skips
/// the call on null (brfalse right after the stloc) rather than handing PlayOneShot a null
/// clip name. The fallback to the big grunt covers an entity that declares SoundHurt but
/// not SoundHurtSmall - again exactly what vanilla's override does when
/// GetSoundHurtSmall() comes back empty.
///
/// PlayOneShot(name) with no further arguments is byte-for-byte what vanilla passes here:
/// its optional parameters default to sound_in_head:false, serverSignalOnly:false,
/// isUnique:false, _animEvent:null, volumeScale:1f, and OnUpdateEntity's own call pushes
/// exactly those five constants. So the grunt comes out of the character, not "in the
/// head", same as being spiked.</summary>
public static void PlayPainSound(EntityPlayerLocal _player, int _damage)
{
if (_player == null)
{
return;
}
string clip = _damage > PainSoundBigDamage
? _player.GetSoundHurt()
: _player.GetSoundHurtSmall();
if (string.IsNullOrEmpty(clip))
{
clip = _player.GetSoundHurt();
}
if (string.IsNullOrEmpty(clip))
{
return;
}
_player.PlayOneShot(clip);
}
public static bool HasAnyKnife(EntityPlayerLocal player) public static bool HasAnyKnife(EntityPlayerLocal player)
{ {
return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots()); return ContainsKnife(player.inventory?.GetSlots()) || ContainsKnife(player.bag?.GetSlots());
@@ -131,6 +209,13 @@ namespace NecromancerTome
return; return;
} }
__state.AddHealth(-amount); __state.AddHealth(-amount);
// The grunt goes AFTER the HP is actually gone, so a craft that somehow bailed out
// above never makes a sound the player cannot account for. At the 90% cost this is the
// big pain clip in every normal case (amount > 15 unless the player is already down to
// about 17 HP), and drops to the small one exactly where vanilla would drop it too.
NecromancerBloodPatch.PlayPainSound(__state, amount);
Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId); Debug.Log("[NecromancerTome] NecromancerBloodPatch: crafted blood, deducted " + amount + " HP from owner=" + __state.entityId);
} }
} }
+7
View File
@@ -44,6 +44,13 @@
<HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath> <HintPath>..\..\..\7DaysToDie_Data\Managed\UnityEngine.AnimationModule.dll</HintPath>
<Private>false</Private> <Private>false</Private>
</Reference> </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 <!-- PlayerActionsLocal.Secondary (PlayerAction) for PortalStonePatch.cs's power-attack
channel-cancel (2026-08-29) - the game's own input layer, not something this mod channel-cancel (2026-08-29) - the game's own input layer, not something this mod
previously needed to touch directly. --> previously needed to touch directly. -->
+172
View File
@@ -0,0 +1,172 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Счёт убийств для скилла "Некромантия" (user report 2026-09-16: "Почему-то наш скилл
/// некроманта не всегда засчитывает убийство зомби... если робомолот убьёт зомбака, то игрок
/// получает за это опыт. Если зомби умрёт от кровотечения, которое навесил игрок, то игрок
/// получит опыт. У нас скилл некроманта в этих случаях не прибавляется. Это баг.").
///
/// WHAT WAS BROKEN, AND IT WAS TWO SEPARATE THINGS.
///
/// Until this patch the whole count was four lines of XML appended to ONE entity class in
/// Config/entityclasses.xml:
///
/// &lt;append xpath="/entity_classes/entity_class[@name='zombieTemplateMale']"&gt;
/// &lt;requirement name="EntityTagCompare" target="other" tags="player"/&gt;
/// &lt;triggered_effect trigger="onOtherKilledSelf" action="ModifyCVar" target="other" .../&gt;
/// &lt;triggered_effect trigger="onOtherKilledSelf" action="AddProgressionLevel" target="other" .../&gt;
///
/// 1. ONE CLASS IS NOT EVERY ZOMBIE. Humanoids were fine - effect_group DOES inherit through
/// extends on entity_class (unlike items.xml, see progression.xml's header), and every
/// zombie template chains back to zombieTemplateMale. But the five zombie ANIMALS inherit
/// the animal branch and never reach it:
/// animalZombieBear extends animalBear, animalZombieBoar extends animalBoar,
/// animalZombieDog extends animalWolf, animalZombieVulture extends animalTemplateHostile,
/// animalZombieVultureRadiated extends animalZombieVulture
/// Killing a zombie dog, bear, boar or vulture counted for nothing at all. Zombie vultures
/// are everywhere on roads, which is most of what "не всегда засчитывает" was.
///
/// 2. target="other" IS THE LITERAL KILLER, NOT THE PLAYER WHO EARNED IT. With
/// trigger="onOtherKilledSelf" plus a requirement that "other" be tagged player, anything
/// that kills on the player's behalf fails the requirement: a robotic sledge (the turret is
/// "other"), a bleed the player applied (no direct killer at the moment of death), a summoned
/// pet (the pet is "other"). Vanilla still awards XP in all of these because it does NOT use
/// the literal killer - it resolves the crediting player from the DamageSource, in
/// EntityAlive.AwardKillXPServer(DamageSource, EntityAlive), whose body reads BuffClass
/// (DoT damage) and a dedicated bTrapKillXP flag (trap kills) before calling AddKillXP.
///
/// BOTH EFFECTS SHARED ONE REQUIREMENT, so every missed kill also failed to raise
/// necroZombieKillsCVar - and that CVar is the Necromancer's Knife's damage (items.xml: "Damage
/// = necroZombieKillsCVar / 10", recomputed continuously in buffs.xml). The bug was quietly
/// underpowering the knife too, which is why the fix keeps both effects together.
///
/// WHY THIS HOOK AND NOT A WIDER XML PATCH. Adding the five animal classes by XML would have
/// fixed cause 1 and left cause 2 untouched. EntityPlayer.AddKillXP is the single point where
/// vanilla has ALREADY decided which player gets the kill - it is called from exactly one place
/// in the whole assembly, AwardKillXPServer, after all the DamageSource resolution is done.
/// Hooking it means our count agrees with the XP number the player sees on screen by
/// construction, for every case vanilla handles, including ones nobody has thought of yet.
/// Verified by metadata scan: AwardKillXPServer is the only caller of AddKillXP.
///
/// THE XML TRIGGERS ARE GONE, NOT LEFT ALONGSIDE. Config/entityclasses.xml no longer carries
/// the effect_group - if it stayed, a kill by the player's own hand would satisfy both it and
/// this patch and count TWICE. That was the one trap of moving the count into code, and it is
/// the first thing to check if levels ever start rising two at a time.
///
/// PETS ARE NOT GUARANTEED BY THIS PATCH. The user also asked that summoned creatures count.
/// They will count if and only if vanilla itself credits the owner for a pet kill - this patch
/// follows vanilla's decision, it does not make it. Whether it does is NOT verified and is the
/// specific thing to watch for in game; if pets turn out not to be credited, that is a separate
/// piece of work (giving the pet's DamageSource an owner), not a bug in this file.
/// </summary>
[HarmonyPatch(typeof(EntityPlayer), "AddKillXP")]
public static class Patch_EntityPlayer_AddKillXP_NecromancyCount
{
public const string NecromancySkillName = "craftingNecroNecromancy";
public const string KillsCVarName = "necroZombieKillsCVar";
/// <summary>The tag every zombie carries, humanoid and animal alike. Checked against the
/// real data rather than assumed: zombieBiker/zombieArlene/zombieBoe/zombieSpider all
/// declare "entity,zombie,..." and the five zombie animals declare
/// "entity,animal,zombie,zombieAnimal,...". Note that entity Tags do NOT inherit through
/// extends (entityclasses.xml says so in a comment right on the property), which is exactly
/// why this works: every concrete, spawnable zombie spells its own tags out, and the bare
/// templates that do not are never spawned.
///
/// A tag test also ages better than the class list it replaces: any zombie added by a
/// future game version or another mod counts the moment it calls itself a zombie.</summary>
private static readonly FastTags<TagGroup.Global> ZombieTag =
FastTags<TagGroup.Global>.Parse("zombie");
public static void Postfix(EntityPlayer __instance, EntityAlive killedEntity)
{
if (__instance == null || killedEntity == null)
{
return;
}
if (!killedEntity.HasAnyTags(ZombieTag))
{
return;
}
AddKillsCVar(__instance);
AddNecromancyLevel(__instance);
}
/// <summary>necroZombieKillsCVar += 1 - the same thing the removed ModifyCVar action did,
/// and the reason it is here rather than left in XML is that it shared the broken
/// requirement with the progression effect. GetCVar/SetCVar are public on EntityAlive and
/// are the same storage the buffs.xml formula reads.</summary>
private static void AddKillsCVar(EntityPlayer _player)
{
_player.SetCVar(KillsCVarName, _player.GetCVar(KillsCVarName) + 1f);
}
/// <summary>+1 level of Necromancy, replicating MinEventActionAddProgressionLevel.Execute
/// step for step rather than inventing a shorter version of it - its IL was read for this:
/// GetProgressionValue, Level + amount, clamp to ProgressionClass.MaxLevel, then (for a
/// crafting skill) the level-up toast and HandleCheckCrafting, then the two dirty flags.
///
/// HandleCheckCrafting is the part that would be easy to drop and expensive to miss: it is
/// what the game calls on a crafting-skill level change, and skipping it risks recipes not
/// noticing they became available. Both it and AddCraftingSkillNotification are public.
///
/// The clamp matters for a different reason than it looks: max_level is 5000, and without
/// the clamp Level would keep climbing past it forever, because nothing else limits it.</summary>
private static void AddNecromancyLevel(EntityPlayer _player)
{
Progression progression = _player.Progression;
if (progression == null)
{
return;
}
ProgressionValue pv = progression.GetProgressionValue(NecromancySkillName);
if (pv == null || pv.ProgressionClass == null)
{
// Not a crash, and not silent either: this means the skill did not load, which is a
// config problem worth seeing once in the log rather than a reason to throw inside
// a kill handler.
Debug.LogWarning("[NecromancerTome] NecromancyKillCredit: progression '" +
NecromancySkillName + "' not found - kill not counted");
return;
}
int oldLevel = pv.Level;
int maxLevel = pv.ProgressionClass.MaxLevel;
int newLevel = oldLevel + 1;
if (newLevel > maxLevel)
{
newLevel = maxLevel;
}
if (newLevel == oldLevel)
{
// Already at 5000. The CVar above still went up on purpose - the knife's damage is
// not capped by the skill's max_level, and the player who is past the cap should
// keep getting stronger knives.
return;
}
pv.Level = newLevel;
EntityPlayerLocal local = _player as EntityPlayerLocal;
if (pv.ProgressionClass.IsCrafting && local != null)
{
// true = add the notification only if one is not already up, so a horde night does
// not stack a fresh toast per corpse.
local.PlayerUI?.xui?.CollectedItemList?.AddCraftingSkillNotification(pv, true);
pv.ProgressionClass.HandleCheckCrafting(local, oldLevel, newLevel);
}
// isEntityRemote guards these in vanilla too: a remote player's stats are the server's
// business, and marking them dirty here would be claiming an authority we do not have.
if (!_player.isEntityRemote)
{
progression.bProgressionStatsChanged = true;
_player.bPlayerStatsChanged = true;
}
}
}
}
+23 -6
View File
@@ -7,10 +7,21 @@ namespace NecromancerTome
/// <summary> /// <summary>
/// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент /// Duke's note ("Записка от Дюка", item noteDuke01) - user request 2026-08-30: "в момент
/// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is /// открытия записки, ставить игру на паузу и проигрывать флэшбек" (at the moment the note is
/// opened, pause the game and play a flashback). Reuses the exact pause+video pipeline /// opened, pause the game and play a flashback). Built on the pause+video pipeline first
/// already built and tested for the Black Portal Stone (see PortalStonePatch.cs's /// written for the Black Portal Stone; since the finale switched to text slides
/// ActivateBlackPortal - GameManager.Instance.Pause/XUiC_VideoPlayer.PlayVideo, both APIs /// (FinalSlides) and its dead video code was removed 2026-09-10, this patch is the only
/// decompiled there already, same reasoning applies unchanged here). /// 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 /// 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: /// 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. /// 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 /// 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 /// 2026-08-30 as exch/flashbback.mp4), and the only video the mod still ships. Kept as
/// Black Portal placeholder: Unity's VideoPlayer component (confirmed by decompiling /// .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 /// 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 /// 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 /// 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")] [HarmonyPatch(typeof(XUiC_MessageBoxWindowGroup), "ShowOkCancel")]
public static class Patch_XUiC_MessageBoxWindowGroup_ShowOkCancel_NoteFlashback 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"; public const string NoteFlashbackVideoPath = "@modfolder(NecromancerTome):Video/DukeNoteFlashback.mp4";
/// <summary>Guards the re-entrant call this patch makes to the very method it patches /// <summary>Guards the re-entrant call this patch makes to the very method it patches
+27 -78
View File
@@ -114,6 +114,10 @@ namespace NecromancerTome
// buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open" // buff-trigger vocabulary, since there's no "for as long as this XUiC_Timer is open"
// trigger to hang it off - this IS that lifecycle. // trigger to hang it off - this IS that lifecycle.
player.Buffs.AddBuff(ChannelBuffName); player.Buffs.AddBuff(ChannelBuffName);
// The world drains to black and white for the length of the channel - see
// ChannelVision.cs. Started here rather than from the buff so both of this mod's
// channels share one definition of what channelling looks like.
ChannelVision.Begin(player);
TimerEventData timerData = new TimerEventData TimerEventData timerData = new TimerEventData
{ {
@@ -136,6 +140,7 @@ namespace NecromancerTome
{ {
Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId); Debug.Log("[NecromancerTome] PortalStonePatch: channel cancelled for " + itemName + ", owner=" + player.entityId);
player.Buffs.RemoveBuff(ChannelBuffName); player.Buffs.RemoveBuff(ChannelBuffName);
ChannelVision.End(player);
}; };
string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling"; string labelKey = (itemName == BlueStoneName) ? "thrownStonePortalBlueChanneling" : "thrownStonePortalBlackChanneling";
@@ -151,6 +156,9 @@ namespace NecromancerTome
{ {
Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId); Debug.Log("[NecromancerTome] PortalStonePatch: channel completed for " + itemName + ", owner=" + player.entityId);
player.Buffs.RemoveBuff(ChannelBuffName); player.Buffs.RemoveBuff(ChannelBuffName);
// Before the teleport rather than after: the colour is already on its way back while
// the player arrives, instead of starting to return only once he is standing there.
ChannelVision.End(player);
if (itemName == BlackStoneName) if (itemName == BlackStoneName)
{ {
ShowBlackPortalConfirmation(player); ShowBlackPortalConfirmation(player);
@@ -159,45 +167,23 @@ namespace NecromancerTome
TeleportToBedroll(player); TeleportToBedroll(player);
} }
/// <summary>Black portal confirmation + fullscreen video, user request 2026-08-30 /// <summary>Black portal confirmation dialog, user request 2026-08-30
/// ("диалоговое окно... вы уверены... Если Да, то игра останавливается и проигрывается /// ("диалоговое окно... вы уверены"). XUiC_MessageBoxWindowGroup.ShowCustom(xui, title,
/// видео"). Real APIs, both decompiled directly: /// text, icon, setupCallback, ...) - the same generic Yes/No popup vanilla itself uses (its
/// - XUiC_MessageBoxWindowGroup.ShowCustom(xui, title, text, icon, setupCallback, ...) - /// own delete-item/disconnect confirmations, etc), decompiled directly. ShowOkCancel/
/// the same generic Yes/No popup vanilla itself uses (its own delete-item/disconnect /// ShowConfirmCancel exist too but hardcode their button caption keys ("xuiOk"/"xuiCancel"/
/// confirmations, etc). ShowOkCancel/ShowConfirmCancel exist too but hardcode their /// "btnConfirm") - ShowCustom's _setupCallback is the only variant that lets the two buttons
/// button caption keys ("xuiOk"/"xuiCancel"/"btnConfirm") - ShowCustom's /// be captioned "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys,
/// _setupCallback is the only variant that lets the two buttons be captioned /// confirmed against Data/Config/Localization.csv), matching the user's literal "да/нет"
/// "xuiYes"/"xuiNo" directly (both are real, already-localized vanilla keys, confirmed /// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/ShowConfirmCancel
/// against Data/Config/Localization.csv), matching the user's literal "да/нет" /// themselves use internally - Buttons[1] is left unused, same as vanilla's own 2-button
/// wording. Buttons[0]/[2] (not [1]) is the same slot pairing ShowOkCancel/ /// dialogs.
/// 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.
/// ///
/// 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 /// после "Да" управление уходит в FinalSlides (см. ActivateBlackPortal ниже), пауза живёт
/// 7DaysToDie_Data/StreamingAssets/Video/), placed there 2026-08-30 as a test stand-in /// там, а видео из финала убрано 2026-09-09. Разбор GameManager.Instance.Pause и
/// ("Пока файл видео замени заглушкой") - deleted 2026-09-09 before the public release, /// XUiC_VideoPlayer.PlayVideo переехал в NoteFlashbackPatch.cs - единственное место в моде,
/// since redistributing a game asset is not ours to do. This path is dead until a real /// где обе эти ванильные API ещё вызываются.</summary>
/// 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";
public static void ShowBlackPortalConfirmation(EntityPlayerLocal player) public static void ShowBlackPortalConfirmation(EntityPlayerLocal player)
{ {
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player); LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player);
@@ -220,55 +206,18 @@ namespace NecromancerTome
} }
/// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео /// <summary>ЗАМЕНЕНО 2026-09-09: раньше отсюда сразу стартовало полноэкранное видео
/// (BlackPortalVideoPath), теперь запускается финальная сцена из шести слайдов с текстом /// (Video/BlackPortal.webm), теперь запускается финальная сцена из шести слайдов с текстом
/// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо /// - FinalSlides.Begin. Причина в BACKLOG.md ("концовка серией диалоговых окон вместо
/// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через /// видео"): видео не локализуется, а текст слайдов идёт обычной строкой через
/// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое /// Localization.csv. Пауза и выход в главное меню никуда не делись - и то и другое
/// теперь живёт внутри FinalSlides, а видео осталось финальным аккордом ПОСЛЕ выбора /// теперь живёт внутри FinalSlides. Видео из концовки убрано целиком 2026-09-09 -
/// концовки на последнем слайде. /// ни здесь, ни в FinalSlides его больше нет.</summary>
///
/// Всё, что описано в комментарии к BlackPortalVideoPath выше, по-прежнему верно и
/// применяется - просто к двум новым файлам (FinalSlides.StayVideoPath /
/// ReturnVideoPath) вместо одного. Сама константа BlackPortalVideoPath больше не
/// используется и оставлена только как документация к разбору "@modfolder(...)" и
/// XUiC_VideoPlayer.PlayVideo, на который FinalSlides ссылается.</summary>
public static void ActivateBlackPortal(EntityPlayerLocal player) public static void ActivateBlackPortal(EntityPlayerLocal player)
{ {
Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides"); Debug.Log("[NecromancerTome] PortalStonePatch: black portal confirmed by owner=" + player.entityId + ", handing over to FinalSlides");
FinalSlides.Begin(player); 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 /// <summary>BedrollPos comes from EntityPlayer.PersistentPlayerData (decompiled - reads
/// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same /// GameManager.Instance.persistentPlayers.GetPlayerDataFromEntityID(entityId)), the same
/// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos / /// field the game's own respawn-at-bedroll flow reads (PersistentPlayerData.BedrollPos /
+56 -23
View File
@@ -30,19 +30,15 @@ namespace NecromancerTome
/// this rounds to 0 - deliberately left as-is, not special-cased away, matching the /// 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 /// 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. /// instead of silently opening a useless empty window.
/// - PERSISTENCE - the one thing NOT fully solved here, flagged rather than silently /// - PERSISTENCE - solved 2026-09-13, see SpatialVaultPersistence.cs. It was NOT solved
/// assumed: the Bag backing each player's vault lives in a plain in-memory /// when this item shipped, and that shortfall is exactly what became the mod's first
/// Dictionary&lt;int, Bag&gt; in this file (PlayerVaults below), keyed by entityId. This /// Nexus bug report (youkia96581, 11 Sep 2026: "Items stored in the space bracelet will
/// is reliable for as long as the game process keeps running (survives death/respawn/ /// disappear after leaving the game and going online again"). PlayerVaults below is still
/// relogging within one play session, confirmed by how a static field behaves) but has /// the in-memory, entityId-keyed Dictionary it always was, but it is now only the session
/// NOT been wired into any save/load system - closing the game entirely and reloading the /// cache: the durable copy is written into the player's own PlayerDataFile, alongside the
/// save later will NOT bring the vault's contents back (no persistence file, no hook into /// backpack, by four postfixes on FromPlayer/ToPlayer/Write/Read. Read that file's comment
/// PersistentPlayerData or a world-save event). Building real cross-session persistence /// for why there ("почему не сделать принцип как у ящика?" - because a chest's items live
/// (a custom save file + ModEvents.GameSave/Load hooks, or piggybacking on an owned /// in a chunk, and the bracelet's closest equivalent home is its owner's save data).
/// 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.
/// ///
/// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at: /// REGULAR ATTACK (index 0) - knock back + slow whatever zombie the crosshair is aimed at:
/// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for /// - Same raycast mechanism HarmonySrc/ThiefLoopPatch.cs already established for
@@ -71,8 +67,9 @@ namespace NecromancerTome
public const float MaxRange = 50f; public const float MaxRange = 50f;
public const float ShoveDistance = 6f; public const float ShoveDistance = 6f;
/// <summary>See the class-level comment above for exactly what this does and doesn't /// <summary>Session cache only - the durable copy lives on disk, see
/// guarantee - session-scoped only, not yet saved/loaded across game restarts.</summary> /// 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 readonly Dictionary<int, Bag> PlayerVaults = new Dictionary<int, Bag>();
public static bool Prefix(ItemActionData _actionData, bool _bReleased) public static bool Prefix(ItemActionData _actionData, bool _bReleased)
@@ -93,13 +90,25 @@ namespace NecromancerTome
if (_actionData.indexInEntityOfAction == 1) if (_actionData.indexInEntityOfAction == 1)
{ {
OpenVault(player); // Not when this very press just cancelled a block pickup and opened the vault on
// the way - see SpatialVaultPickup.ConsumeCancelOpen. Down and up are one press.
if (!SpatialVaultPickup.ConsumeCancelOpen())
{
OpenVault(player);
}
}
else
{
// The regular attack takes the block under the crosshair into the vault from
// 2026-09-14 - see SpatialVaultPickupPatch.cs. Before that it did nothing at all,
// by direct user request of 2026-08-30 ("пусть тогда обычная атака у
// пространственного браслета не делает ничего"), after the knockback+slow version did not
// visibly do anything in testing. ShoveZombieAtCrosshair is kept below, unused,
// because that abandoned version was never shown to be WRONG - only invisible.
// The ItemValue goes with it: the pickup refuses outright when this particular
// bracelet has nothing in its mod slot, and mods live on the instance.
SpatialVaultPickup.Begin(player, _actionData.invData.itemValue);
} }
// else: regular attack (index 0) deliberately does nothing, per direct user request
// 2026-08-30 ("пусть тогда обычная атака у пространственного браслета не делает
// ничего") after the knockback+slow version didn't visibly do anything in testing -
// rather than debug ShoveZombieAtCrosshair blind (kept below, unused, in case this
// gets revisited), just absorb the click silently.
// Skip ItemActionEat's own logic entirely - the click has been fully handled here. // Skip ItemActionEat's own logic entirely - the click has been fully handled here.
return false; return false;
@@ -118,7 +127,17 @@ namespace NecromancerTome
if (!PlayerVaults.TryGetValue(player.entityId, out Bag bag)) 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; PlayerVaults[player.entityId] = bag;
} }
else if (bag.SlotCount < slotCount) else if (bag.SlotCount < slotCount)
@@ -134,7 +153,21 @@ namespace NecromancerTome
Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")"); Debug.Log("[NecromancerTome] SpatialVaultPatch: owner=" + player.entityId + " opened vault, " + slotCount + " slots (Necromancy level " + level + ")");
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(player); 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) 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);
}
}
}
}
+681
View File
@@ -0,0 +1,681 @@
using HarmonyLib;
using UnityEngine;
namespace NecromancerTome
{
/// <summary>
/// Holding the Spatial Bracelet's REGULAR attack on a block takes that block into the vault
/// after a ten-second timer - the same circular indicator a workbench shows when you take it
/// (user request 2026-09-14: "при зажатии обычной атаки игрок видел индикатор как при
/// демонтаже верстака... блок должен исчезнуть и появиться в пространственном хранилище").
/// Entry point is SpatialVaultPatch's existing Prefix, index 0, which until now deliberately
/// swallowed that click and did nothing.
///
/// THE WHOLE RECIPE IS VANILLA'S, not an imitation of it. Block.TakeItemWithTimer and its
/// TakeItemWithTimerDone are short enough to read in one sitting, and they are the workbench
/// pickup; what follows is the same sequence with two substitutions - ten seconds instead of
/// the block's own TakeDelay, and the vault's Bag instead of the player's backpack. Even the
/// refusal messages are vanilla's own keys, which means they are already translated into every
/// language the game ships, and a player who has ever taken a workbench has already been
/// taught what they mean.
///
/// A DAMAGED BLOCK IS REFUSED BEFORE THE TIMER EVER OPENS. That is vanilla's first line:
///
/// if (_blockValue.damage > 0)
/// GameManager.ShowTooltip(_player, Localization.Get("ttRepairBeforePickup"), "", "ui_denied");
/// else if (canTake(...))
/// XUiC_Timer.OpenTimer(...);
///
/// - and it is exactly what the user asked for: a message, and no indicator at all.
///
/// EVERY GUARD IS CHECKED TWICE, ONCE TO OPEN THE TIMER AND ONCE TO FINISH IT, because ten
/// seconds is a long time in this game. Vanilla does the same for its own two seconds: the
/// block can be shot, mined, replaced, or opened by someone else while the circle fills, and
/// each of those has its own message rather than a silent failure or, worse, a block quietly
/// deleted from the world with nothing to show for it.
///
/// THE TARGET IS ANY BLOCK UNDER THE CROSSHAIR (user's choice of 2026-09-14, over the
/// narrower "only what vanilla already lets you take"). That is a wider promise than vanilla
/// ever makes, and two things follow from it that the narrow version would never have had to
/// face:
///
/// - MULTIBLOCKS. A door or a bed occupies several cells, and the crosshair usually lands on
/// a child rather than on the parent. Setting that one cell to air would leave the other
/// half standing as debris. The child is resolved to its parent first, with the engine's
/// own idiom - `isMultiBlock && ischild -> multiBlockPos.GetParentPos(...)` - which is
/// what Block's own methods do a dozen times over, and the parent is what gets removed.
/// - BLOCKS WITH NO ITEM FORM. Not everything placed in the world converts to something a
/// player can hold; ToItemValue comes back empty for those. They are refused up front,
/// because the alternative is deleting a block and handing back nothing.
///
/// THE CHANNEL GETS LONGER WITH REACH - ten seconds against the block, one more per full
/// block of distance. The measurement is not computed from the player's position and the
/// block's position, which would mean picking a point in the player (feet? eyes?) and a point
/// in the block (centre? face?) and being wrong about one of them: the engine already fills in
/// HitInfoDetails.distanceSq for the very ray that chose this block, so the number used is the
/// length of that ray. It is also the honest one - it measures to the surface being looked at,
/// which is what "вплотную" means to a player standing against a wall.
///
/// FLOOR, NOT ROUND, and that is what makes the two anchors in the request both come out
/// right: flush against a block the ray is well under a metre, floors to zero, and the channel
/// is the plain ten seconds; a block five away floors to five and costs fifteen.
///
/// THE POWER ATTACK CANCELS THE CHANNEL AND OPENS THE VAULT (user request 2026-09-14, after
/// the feature was confirmed working: "можно случайно нажать и не иметь возможности прервать").
/// Ten seconds of standing still after a misclick is a long time, and the vanilla escapes are
/// both poor here: getting hit is not something the player chooses, and the activate key is
/// not the button a hand is already on. The bracelet's other button is - and it lands on the
/// thing the player most likely wanted in the first place.
///
/// WHAT A PICKAXE CANNOT BREAK, THE BRACELET CANNOT TAKE (user report 2026-09-14: it would
/// happily take a trader's compound apart, and bedrock with it). TWO SEPARATE ENGINE RULES
/// stand behind that one sentence, and they are worth keeping apart because they look
/// identical from inside the game and are nothing alike in the code:
///
/// - A TRADER'S GROUND. The blocks there are ordinary; it is the AREA that is protected.
/// Vanilla simply skips DamageBlock inside it, which is why a pickaxe does nothing while
/// this bracelet - asking about the block rather than about the place - saw nothing wrong.
/// The test is the same predicate that suppression uses, with its condition copied whole:
///
/// World.SandboxUseTraderArea != TraderAreaStates.Default || !world.IsWithinTraderArea(pos)
///
/// The sandbox half is not padding. Trader protection is a server setting, and a server
/// that turned it off should not find this mod enforcing it anyway: where vanilla
/// protects, so does the bracelet; where it does not, neither does this.
/// - INDESTRUCTIBLE MATERIAL. The world's floor is the opposite case - nothing special about
/// the place, everything special about the block. Bedrock's material carries
/// CanDestroy=false (Data/Config/materials.xml, Mbedrock), and the engine reads exactly
/// `blockValue.Block.blockMaterial.CanDestroy` wherever it must not break something. Asked
/// as a material question rather than by block name, so it covers whatever else in this
/// game - or in another mod - is declared unbreakable.
///
/// Both say so out loud, where vanilla stays silent. Vanilla can afford silence because a
/// pickaxe that does nothing is its own explanation - the block visibly refuses to break. An
/// indicator that simply never appears looks like this mod is broken instead, so these
/// refusals get a message like every other one in this file.
///
/// CONTENTS CANNOT TRAVEL, AND THAT IS NOT A SHORTCUT. "In the state the original block was
/// in" holds for the block's identity and its integrity, but an ItemStack in this game has
/// nowhere to put another container's inventory - ToItemValue maps a block to an item and
/// stops there. Vanilla solves this by refusing: a workstation with anything in it cannot be
/// taken, and says so through ttWorkstationNotEmpty. The same refusal is used here, extended
/// to composite storage (chests) through ITileEntityLootable, which is how this version of the
/// game models a container's contents.
/// </summary>
public static class SpatialVaultPickup
{
/// <summary>The floor: what it costs to take a block you are standing against. Vanilla's
/// workbench is two; the Blue Portal Stone's channel in this mod is also ten, and this
/// reads as the same kind of deliberate act.</summary>
public const float BaseChannelSeconds = 10f;
/// <summary>Added per full block of reach (user request 2026-09-14: "вплотную 10 сек,
/// если объект от персонажа в пяти блоках то 15 сек"). Distance is a cost, so pulling
/// something out of a wall across the room is a commitment rather than a trick.</summary>
public const float SecondsPerBlock = 1f;
/// <summary>Vanilla's own refusal messages, already translated into every shipped
/// language. Reused rather than re-worded: a player who has taken a workbench has already
/// learned what these mean, and a second vocabulary for the same refusal would be worse
/// than no message.</summary>
public const string MsgRepairFirst = "ttRepairBeforePickup";
public const string MsgBlockMissing = "ttBlockMissingPickup";
public const string MsgInUse = "ttCantPickupInUse";
public const string MsgNotEmpty = "ttWorkstationNotEmpty";
/// <summary>This mod's own, added with this feature - see Config/Localization.csv.</summary>
public const string MsgNoBlock = "braceletSpatialVaultNoBlock";
public const string MsgNoItemForm = "braceletSpatialVaultNoItemForm";
public const string MsgVaultFull = "braceletSpatialVaultFull";
public const string MsgChanneling = "braceletSpatialVaultPickupChanneling";
public const string MsgTraderArea = "braceletSpatialVaultTraderArea";
public const string MsgIndestructible = "braceletSpatialVaultIndestructible";
public const string MsgNoMod = "braceletSpatialVaultNoMod";
/// <summary>The denial sound vanilla plays with these tooltips.</summary>
public const string DeniedSound = "ui_denied";
/// <summary>What the bracelet burns to pull a block: the Кровавая сфера, dictated
/// 2026-09-15. Its own definition is in Config/item_modifiers.xml.
///
/// WHY A SEPARATE ITEM AND NOT THE BLOOD ITSELF - this is the scar of the 15.09 accident
/// and the reason not to "simplify" it back. The blood was moved into item_modifiers.xml
/// so it could be installed here, and that destroyed a character's save along with its
/// backup: an item's CLASS decides the byte layout of every stack of it
/// (ItemValue.Read:1094 / Write:1228), so a save written before the move became
/// unreadable. The sphere is a NEW name that no old save contains, which is what makes it
/// safe. Full write-up in BACKLOG.md.
///
/// The Кровавый камень, when it exists, goes in the same slot and is NOT charged here -
/// it is the infinite one. Nothing to add for it: this check names the sphere, so
/// anything else in the slot simply pays nothing.</summary>
public const string ChargeItemName = "resourceBloodSphere";
/// <summary>Unscaled time at which a cancel last opened the vault, or -1. Exists to stop
/// ONE press from opening the vault TWICE: the cancel reacts to the button going down,
/// while the bracelet's ordinary power attack reacts to it coming back up, and those are
/// the same press. Whether the release even reaches the item action through the modal
/// window is unknown - it is exactly the input suppression that forced the raw mouse read
/// below - so this guards the case rather than assuming either answer.</summary>
public static float CancelOpenedVaultAt = -1f;
/// <summary>How long after a cancel a power-attack release is treated as the tail of that
/// same press. Long enough to cover a slow finger, far short of a deliberate second
/// click.</summary>
public const float CancelSwallowSeconds = 0.5f;
/// <summary>True once, if the vault was just opened by cancelling a channel. Consuming it
/// rather than only reading it means a genuine second press right afterwards still
/// works.</summary>
public static bool ConsumeCancelOpen()
{
if (CancelOpenedVaultAt < 0f || Time.unscaledTime - CancelOpenedVaultAt > CancelSwallowSeconds)
{
return false;
}
CancelOpenedVaultAt = -1f;
return true;
}
/// <summary>What the timer is working on, handed through TimerEventData.Data - the same
/// use vanilla makes of that field (it packs a BlockValue, a position and the player into
/// an object[] there). A small class instead of an array because this one is read back in
/// a method that has to be right about which field is which.</summary>
public class PickupJob
{
public EntityPlayerLocal Player;
public Vector3i Position;
public BlockValue Expected;
/// <summary>The bracelet this pull was started with, so the blood that pays for it is
/// taken from the flask in THAT bracelet. Kept as the live ItemValue rather than
/// looked up again at the end: mods live on the instance, and ten seconds is long
/// enough for the player to have scrolled to another slot.</summary>
public ItemValue Bracelet;
/// <summary>What this particular pull costs, in seconds - computed once when the
/// channel starts (user request 2026-09-15: "пусть количество секунд требуемое для
/// поглощения блока записывается в отдельную переменную"). It is the same number the
/// timer counts down and the same number the flask pays, and that is the point of
/// storing it instead of recomputing: by the time the channel ends the player may
/// have turned away, the ray is gone, and a second call to ChannelSecondsFor would
/// quietly charge for a different block than the one that was taken.</summary>
public float ChannelSeconds;
}
/// <summary>Regular attack on the bracelet. Every refusal happens here, before the player
/// is asked to stand still for ten seconds.</summary>
public static void Begin(EntityPlayerLocal _player, ItemValue _bracelet)
{
// AN EMPTY MOD SLOT REFUSES THE WHOLE ACTION (user request 2026-09-15: "пусть обычная
// атака (поглощение блока) у пространственного хранилища не работает, если у хранилища
// в слоте модификаций пусто"). FIRST, deliberately, ahead of every other check in this
// method: the others are about the TARGET (no block, wrong kind of block, vault full),
// and telling the player "no block there" when the real problem is his empty bracelet
// would send him looking in the wrong place. This one is about the tool, so it is
// answered before the tool is even pointed at anything.
//
// ItemValue.HasMods() is the game's own test and the right one: it walks Modifications
// only, skipping both nulls and IsEmpty() slots, and does NOT count CosmeticMods - a
// dye would otherwise have read as "the bracelet is loaded". The bracelet has no
// cosmetic slot anyway (canHaveCosmetic is deliberately absent from its Tags, see
// items.xml), so this is belt and braces rather than a live case - but the next item
// that reuses this pattern may well have one.
//
// The ItemValue is handed in rather than read from the player, because the caller
// already holds the exact instance the click came from (_actionData.invData.itemValue)
// and mods live on the INSTANCE, not on the ItemClass. Two bracelets in the same
// inventory can legitimately disagree about whether they are loaded.
if (_bracelet == null || !_bracelet.HasMods())
{
Deny(_player, MsgNoMod);
return;
}
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null)
{
return;
}
WorldRayHitInfo hitInfo = _player.HitInfo;
if (hitInfo == null || !hitInfo.bHitValid)
{
Deny(_player, MsgNoBlock);
return;
}
Vector3i position = hitInfo.hit.blockPos;
BlockValue blockValue = world.GetBlock(position);
if (blockValue.isair || blockValue.Block == null)
{
Deny(_player, MsgNoBlock);
return;
}
// A door or a bed is several cells and the crosshair lands on whichever one is
// nearest; removing that cell alone would leave the rest of the model standing.
if (blockValue.Block.isMultiBlock && blockValue.ischild)
{
position = blockValue.Block.multiBlockPos.GetParentPos(position, blockValue);
blockValue = world.GetBlock(position);
if (blockValue.isair || blockValue.Block == null)
{
Deny(_player, MsgNoBlock);
return;
}
}
// Before anything else about the block is considered: whether it may be touched at
// all outranks what state it happens to be in.
if (!CanTakeHere(world, position, blockValue, _player))
{
return;
}
// Vanilla's first line, and the user's explicit requirement: a damaged block gets the
// message and no indicator whatsoever.
if (blockValue.damage > 0)
{
Deny(_player, MsgRepairFirst);
return;
}
ItemValue itemValue = blockValue.ToItemValue();
if (itemValue == null || itemValue.IsEmpty())
{
Deny(_player, MsgNoItemForm);
return;
}
if (!CanTakeTileEntity(world, position, _player))
{
return;
}
// Asked before the timer rather than after it, because ten seconds spent to be told
// the vault was full the whole time is the worst version of this feature.
Bag bag = GetVault(_player);
if (bag == null)
{
return;
}
if (!bag.CanTakeItem(new ItemStack(itemValue, 1)))
{
Deny(_player, MsgVaultFull);
return;
}
// Computed HERE, while the ray still exists, and carried in the job from this point
// on - see PickupJob.ChannelSeconds.
float channelSeconds = ChannelSecondsFor(hitInfo);
TimerEventData timerData = new TimerEventData
{
Data = new PickupJob
{
Player = _player,
Position = position,
Expected = blockValue,
Bracelet = _bracelet,
ChannelSeconds = channelSeconds
},
// Vanilla's own two escapes: taking a hit stops the channel, and so does the
// activate key. Neither is built here - both are fields XUiC_Timer.Update reads.
CloseOnHit = true,
CancelWithActivateButton = true
};
timerData.FullTimeFinishEvent += OnChannelComplete;
// Every way this ends that is NOT completion: damage, the activate key, the power
// attack. XUiC_Timer sets skipCloseEvent around the completion path specifically so
// the two are mutually exclusive, which is why the colour is restored in both places
// and not only here.
timerData.CloseEvent += delegate
{
ChannelVision.End(_player);
};
LocalPlayerUI playerUI = LocalPlayerUI.GetUIForPlayer(_player);
XUiC_Timer.OpenTimer(playerUI.xui, channelSeconds, timerData, -1f, Localization.Get(MsgChanneling));
// After the window is up, so a channel that somehow fails to open never leaves the
// world grey with nothing running.
ChannelVision.Begin(_player);
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + _player.entityId + " started taking " +
blockValue.Block.GetBlockName() + " at " + position + " - " +
Mathf.Sqrt(hitInfo.hit.distanceSq).ToString("0.##") + " blocks away, " +
channelSeconds.ToString("0.#") + "s channel");
}
/// <summary>Ten seconds later. Everything is checked again from the live world rather than
/// trusted from the job, because the block that was there when the circle started filling
/// is not necessarily the block that is there now.</summary>
public static void OnChannelComplete(TimerEventData _timerData)
{
if (!(_timerData.Data is PickupJob job) || job.Player == null)
{
return;
}
// FIRST, before any of the checks below can take an early exit: the ten seconds are
// over however this turns out, so the colour comes back whether the block is taken or
// refused.
ChannelVision.End(job.Player);
World world = GameManager.Instance != null ? GameManager.Instance.World : null;
if (world == null)
{
return;
}
BlockValue blockValue = world.GetBlock(job.Position);
if (!CanTakeHere(world, job.Position, blockValue, job.Player))
{
return;
}
if (blockValue.damage > 0)
{
Deny(job.Player, MsgRepairFirst);
return;
}
// Shot out, mined, or replaced while the circle was filling.
if (blockValue.isair || blockValue.Block == null || blockValue.type != job.Expected.type)
{
Deny(job.Player, MsgBlockMissing);
return;
}
if (!CanTakeTileEntity(world, job.Position, job.Player))
{
return;
}
ItemValue itemValue = blockValue.ToItemValue();
if (itemValue == null || itemValue.IsEmpty())
{
Deny(job.Player, MsgNoItemForm);
return;
}
Bag bag = GetVault(job.Player);
if (bag == null)
{
return;
}
// ORDER MATTERS: the item goes in first, and the block is only removed if it got
// there. The other way round is how a block gets deleted out of the world in exchange
// for nothing when the vault filled up during those ten seconds.
if (!bag.AddItem(new ItemStack(itemValue, 1)))
{
Deny(job.Player, MsgVaultFull);
return;
}
world.SetBlockRPC(job.Position, BlockValue.Air);
// AFTER the block is gone and the item is in the vault, never before: every refusal
// above returns early, and blood paid for a pull that was then refused would be blood
// charged for nothing. This is the only place the flask is spent.
float spent = SpendCharge(job);
// The vault lives in memory and is written out with the player's own save data; this
// is the same commit point closing the vault window uses, so a block taken and then
// left alone is not waiting on the next autosave to become real. The flask's UseTimes
// rides along in the same save - it lives on the bracelet in the player's inventory.
GameManager.Instance.SaveLocalPlayerData();
Debug.Log("[NecromancerTome] SpatialVaultPickup: owner=" + job.Player.entityId + " took " +
blockValue.Block.GetBlockName() + " at " + job.Position + " into the vault for " +
spent.ToString("0.#") + " of charge");
}
/// <summary>Charges this pull to the Кровавая сфера in the bracelet's mod slot, one point
/// of durability per second of channel (user request 2026-09-15). Returns what was
/// actually taken.
///
/// WHY THE NAME CHECK AND NOT "whatever is in the slot". The slot is meant to take the
/// Кровавый камень too, and that one is explicitly the infinite version - it must pay
/// nothing. Naming the sphere here gets that for free: anything else installed is simply
/// not charged, and the pull still happens.
///
/// NOT ENOUGH BLOOD IS NOT A REFUSAL (user request 2026-09-15: "пусть поглощение всё-равно
/// сработает, но флакон крови некроманта после этого пусть исчезнет из слота", clarified to
/// "если прочность 0 или меньше, пусть флакон исчезнет из слота"). So the last pull is
/// always free of charge in the sense that matters - it completes - and the flask simply
/// does not survive it. The charge is therefore NOT clamped: UseTimes is allowed to go past
/// MaxUseTimes, because the only thing that then reads it is the emptiness test right
/// below, and a clamp would have made "spent exactly to zero" and "overdrawn" look the
/// same at the moment the difference stopped mattering anyway.
///
/// The test is "durability 0 or less", not "could not cover the cost", and those are not
/// the same rule: a flask with exactly enough left is also gone afterwards. That is the
/// user's own correction and it closes the hole the first version would have left - a
/// flask sitting at 0/1000 in the slot, counting as "something is installed" for the empty-
/// slot gate in Begin, and pulling blocks for free forever.
///
/// WHY THE NAME CHECK AND NOT "whatever is in the slot" - see above; a mod that is not
/// blood pays nothing, is not emptied, and the pull still happens.</summary>
public static float SpendCharge(PickupJob _job)
{
ItemValue bracelet = _job.Bracelet;
if (bracelet == null || bracelet.Modifications == null || _job.ChannelSeconds <= 0f)
{
return 0f;
}
float spent = 0f;
bool emptied = false;
for (int i = 0; i < bracelet.Modifications.Length; i++)
{
ItemValue mod = bracelet.Modifications[i];
if (mod == null || mod.IsEmpty())
{
continue;
}
if (mod.ItemClass == null || mod.ItemClass.Name != ChargeItemName)
{
continue;
}
int max = mod.MaxUseTimes;
mod.UseTimes += _job.ChannelSeconds;
spent += _job.ChannelSeconds;
// max <= 0 means this flask has no durability at all - a DegradationMax that did
// not resolve. Draining something with no capacity would delete it on the first
// pull, which is a config bug eating the player's item, so it is left alone and
// said out loud instead.
if (max <= 0)
{
Debug.LogWarning("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
" has MaxUseTimes 0 - nothing to spend, flask kept. Check the " +
"DegradationMax passive_effect in Config/item_modifiers.xml");
continue;
}
if (mod.UseTimes >= max)
{
// ItemValue.None is what an empty mod slot holds - type 0, which is exactly
// what IsEmpty() tests for, so the slot reads as free to every other piece of
// code including the gate in Begin.
bracelet.Modifications[i] = ItemValue.None;
emptied = true;
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i +
" ran out (" + mod.UseTimes.ToString("0.#") + "/" + max +
") - sphere removed from the bracelet");
}
else
{
Debug.Log("[NecromancerTome] SpatialVaultPickup: sphere in slot " + i + " now " +
mod.UseTimes.ToString("0.#") + "/" + max + " used");
}
}
if (emptied)
{
// Vanilla's own answer to "the thing you were using is gone" - the same cue
// ItemAction.HandleItemBreak plays. An item vanishing out of a slot in silence is
// the one outcome here the player could miss entirely.
_job.Player.PlayOneShot("itembreak");
}
if (spent > 0f && _job.Player.inventory != null)
{
// Without this the number is right and the bar on the toolbelt icon is stale
// until something else happens to redraw it.
_job.Player.inventory.CallOnToolbeltChangedInternal();
}
return spent;
}
/// <summary>How long this particular pull takes. See the class comment for why the ray's
/// own length is the measurement and why it is floored rather than rounded.</summary>
public static float ChannelSecondsFor(WorldRayHitInfo _hitInfo)
{
float distance = Mathf.Sqrt(_hitInfo.hit.distanceSq);
int blocks = Mathf.Max(0, Mathf.FloorToInt(distance));
return BaseChannelSeconds + blocks * SecondsPerBlock;
}
/// <summary>False (with the reason already shown) when this block is one the game itself
/// would not let a player break - because of where it stands, or because of what it is
/// made of. Split out because, like every other guard here, it is asked twice: once to
/// open the timer and once to finish it.</summary>
public static bool CanTakeHere(World _world, Vector3i _position, BlockValue _blockValue, EntityPlayerLocal _player)
{
if (World.SandboxUseTraderArea == TraderAreaStates.Default && _world.IsWithinTraderArea(_position))
{
Deny(_player, MsgTraderArea);
return false;
}
if (_blockValue.Block != null && _blockValue.Block.blockMaterial != null &&
!_blockValue.Block.blockMaterial.CanDestroy)
{
Deny(_player, MsgIndestructible);
return false;
}
return true;
}
/// <summary>False (with the reason already shown) when a tile entity at this position
/// stands in the way: someone has it open, or it has something inside it. Contents cannot
/// travel inside an ItemStack, so a container has to be emptied first - vanilla's own rule
/// for its workstations, applied here to chests as well.</summary>
public static bool CanTakeTileEntity(World _world, Vector3i _position, EntityPlayerLocal _player)
{
TileEntity tileEntity = _world.GetTileEntity(_position);
if (tileEntity == null)
{
return true;
}
if (tileEntity.IsUserAccessing())
{
Deny(_player, MsgInUse);
return false;
}
if (tileEntity is TileEntityWorkstation workstation && !workstation.IsEmpty)
{
Deny(_player, MsgNotEmpty);
return false;
}
if (tileEntity is TileEntityCollector collector && !collector.IsEmpty())
{
Deny(_player, MsgNotEmpty);
return false;
}
// Chests and everything else that holds loot: this version of the game models them as
// a composite tile entity with a storage FEATURE rather than as their own class, so
// the question has to be asked of the feature - the same TryGetSelfOrFeature call the
// engine's own storage code uses.
if (tileEntity.TryGetSelfOrFeature(out ITileEntityLootable lootable) && !lootable.IsEmpty())
{
Deny(_player, MsgNotEmpty);
return false;
}
return true;
}
/// <summary>The player's vault, or null with the reason already shown. Deliberately the
/// SAME bag the bracelet's power attack opens, reached through the same cache - a block
/// taken here has to be in the window that opens there, and the level gate has to answer
/// the same way in both places.</summary>
public static Bag GetVault(EntityPlayerLocal _player)
{
ProgressionValue progressionValue = _player.Progression?.GetProgressionValue(
Patch_ItemActionEat_ExecuteAction_SpatialVault.NecromancySkillName);
int level = progressionValue != null ? progressionValue.Level : 0;
int slotCount = Mathf.RoundToInt(level / 10f);
if (slotCount <= 0)
{
Deny(_player, "braceletSpatialVaultTooWeak");
return null;
}
if (!Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults.TryGetValue(_player.entityId, out Bag bag))
{
bag = SpatialVaultPersistence.LastLoadedVault ?? new Bag(slotCount);
Patch_ItemActionEat_ExecuteAction_SpatialVault.PlayerVaults[_player.entityId] = bag;
}
if (bag.SlotCount < slotCount)
{
ItemStack[] oldSlots = bag.GetSlots();
ItemStack[] newSlots = ItemStack.CreateArray(slotCount);
System.Array.Copy(oldSlots, newSlots, oldSlots.Length);
bag.SetSlots(newSlots);
}
return bag;
}
/// <summary>A refusal, in vanilla's shape: the tooltip plus the denial sound. One method
/// so that no refusal in this file can accidentally go out silent.</summary>
public static void Deny(EntityPlayerLocal _player, string _localizationKey)
{
GameManager.ShowTooltip(_player, Localization.Get(_localizationKey), string.Empty, DeniedSound);
}
}
/// <summary>Lets the power attack cancel a block pickup in progress and open the vault
/// instead. A separate patch class on XUiC_Timer.Update, not on the item action, because this
/// has to be asked every frame WHILE the timer is open rather than once at click time - the
/// same shape Patch_XUiC_Timer_Update_PortalStoneCancel already uses for the Blue Portal
/// Stone's channel.
///
/// BOTH INPUT CHECKS ARE DELIBERATE, AND THE RAW ONE IS THE ONE THAT WORKS. The portal stone
/// shipped with only the semantic PlayerActionsLocal.Secondary check and the user reported
/// that cancelling did not work at all: the modal timer window has input focus, and the press
/// never reached PlayerAction's polling layer. The fix there was a second, independent read of
/// Unity's raw Input.GetMouseButtonDown(1) - right mouse, confirmed as Secondary's real
/// default KBM binding by decompiling PlayerActionsLocal.CreateActions - which reads hardware
/// state directly and bypasses whatever swallows the other one. That lesson is reused here
/// rather than re-learned: the semantic check is kept because it costs nothing and would cover
/// a gamepad's Secondary if that one does get through, and the raw check is what is actually
/// expected to fire. A gamepad-only player still has no cancel - the same open gap the portal
/// stone has, and the same fix would close both.
///
/// THE TIMER IS CLOSED BEFORE THE VAULT IS OPENED, not after: closing runs OnClose, which is
/// what hands control back to the player and drops the event data. Opening a window on top of
/// one that is still closing is how two windows end up fighting over the same input.</summary>
[HarmonyPatch(typeof(XUiC_Timer), "Update")]
public static class Patch_XUiC_Timer_Update_VaultPickupCancel
{
public static void Postfix(XUiC_Timer __instance)
{
if (__instance == null || __instance.eventData == null ||
!(__instance.eventData.Data is SpatialVaultPickup.PickupJob job) || job.Player == null)
{
return;
}
PlayerActionsLocal input = __instance.xui?.playerUI?.playerInput;
bool cancelPressed = (input != null && input.Secondary.WasPressed) || Input.GetMouseButtonDown(1);
if (!cancelPressed)
{
return;
}
EntityPlayerLocal player = job.Player;
Debug.Log("[NecromancerTome] SpatialVaultPickup: pickup cancelled via power attack by owner=" + player.entityId);
__instance.xui.playerUI.windowManager.Close(__instance.windowGroup);
SpatialVaultPickup.CancelOpenedVaultAt = Time.unscaledTime;
Patch_ItemActionEat_ExecuteAction_SpatialVault.OpenVault(player);
}
}
}
+1 -1
View File
@@ -4,6 +4,6 @@
<DisplayName value="Necromancer's Tome" /> <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." /> <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" /> <Author value="Alex Cube" />
<Version value="1.0.0" /> <Version value="1.2.0" />
<Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" /> <Website value="https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/" />
</xml> </xml>
+5 -3
View File
@@ -20,10 +20,10 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
[b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal: [b]A skill that levels from kills, not perk points.[/b] Necromancy counts every zombie you put down. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal:
[list] [list]
[*][b]Adept[/b] (from the start) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits [*][b]Adept[/b] (from the start) - Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits, Blood Sphere
[*][b]Adept[/b] (20 / 30 / 60 / 100 / 300) - Spatial Bracelet, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense [*][b]Adept[/b] (20 / 30 / 60 / 100 / 300) - Spatial Bracelet, then four knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense
[*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm [*][b]Journeyman[/b] (500 / 1400 / 1700) - Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm
[*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin [*][b]Apprentice[/b] (2000) - Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin, Blood Stone
[*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll [*][b]Necromancer[/b] (3000) - Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll
[*][b]Master[/b] (5000) - Black Portal Stone [*][b]Master[/b] (5000) - Black Portal Stone
[/list] [/list]
@@ -36,7 +36,9 @@ To uninstall, delete the folder. The mod adds items and one block, so a save tha
[b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius. [b]Base defence that converts instead of killing.[/b] The [b]Pyramid of Spirits[/b] is a deployable block. While you stand in its radius it charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning it against the rest instead of your walls. Its block menu toggles the effect and shows the radius.
[b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can. [b]Necromancer's tools.[/b] The [b]Blue Portal Stone[/b] teleports you to your bedroll after a ten-second channel that any damage interrupts, and is never consumed. The [b]Spatial Bracelet[/b] opens a personal storage rift that grows with your Necromancy level - and holding its regular attack on a block pulls that block straight into the rift, ten seconds up close and one more per block of distance, while the world drains to black and white around you. That pull runs on a charge in the bracelet's mod slot: the [b]Blood Sphere[/b] is spent a point per second and crumbles when empty, while the [b]Blood Stone[/b] from the third grade is never spent at all. [b]Necromancer's Blood[/b] is paid for in your own health. [b]Tin cans[/b] add a reusable water cycle - fill, boil on a campfire without a pot, drink, keep the can.
[b]The dead keep shop.[/b] Every trader in the world is rendered in black and white, half-transparent and matte. Nothing about the trade changes - but the necromancer deals with the dead, and the only people still doing business out here are not quite alive.
[b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described. [b]A real ending.[/b] The [b]Black Portal Stone[/b] is the last thing the skill tree gives you, and it is the end of the mod's story - a full-screen finale that stops the game and closes on a choice of two. One of them ends the run and returns you to the main menu; the other lets you come back and keep playing. What waits on the far side is better seen than described.
+44
View File
@@ -0,0 +1,44 @@
[size=5][b]Necromancer's Tome 1.0.1[/b][/size]
A bug-fix release on top of 1.0. Same content, one real fix and a few refinements. Drop-in replacement: delete the old [b]NecromancerTome[/b] folder, unpack this one in its place. Your save is fine.
[size=5][b]Fixed[/b][/size]
[b]The Spatial Bracelet no longer loses what you put in it.[/b] Reported by [b]youkia96581[/b] - thank you, this was a real hole and a fair catch. The storage rift was only ever held in memory: it survived death, respawn and relogging, but quitting the game threw it away.
It is now saved the way the game saves your backpack - inside your own player data, written and read in the same moment and the same file. Put things in, quit, come back days later: they are still there. Verified end to end, including a clean exit to the main menu, which was the exact moment things used to disappear.
[b]One honest caveat:[/b] this cannot bring back items lost in 1.0. There was no data on disk to recover - the vault simply was never written anywhere. Anything already lost is gone, and I am sorry about that.
[size=5][b]Changed[/b][/size]
[list]
[*][b]Traders are drawn in black and white.[/b] The necromancer deals with the dead, and the people still willing to trade with him have clearly seen too much. Their own shaders and lighting are untouched - only the colour is gone, plus a sliver of transparency.
[*][b]The Spatial Bracelet has four mod slots.[/b] Empty for now: the mods that fit them come in a later version. Ordinary weapon mods will not go in, and nothing else will take these.
[*][b]The Spatial Bracelet is no longer a parcel in your hand.[/b] It used to borrow a food-crop prefab and looked like a boxed lunch tied with string. Now nothing is drawn at all - just your fist, held the way you hold a block you are about to place.
[*][b]Grave's Repose is much stronger[/b] - cold and heat resistance raised from 5 to 50. With the knife in hand, weather stops being a problem rather than merely being survivable.
[/list]
[size=5][b]Note for existing saves[/b][/size]
A Spatial Bracelet you already own will not gain the four mod slots. The game fixes an item's slot count when the item is created, so an old one keeps the zero it was made with. [b]Craft a new bracelet[/b] and it will have them. Everything else in this release applies to your existing save immediately.
[size=5][b]Requirements[/b][/size]
Unchanged from 1.0: [b]7 Days to Die 3.2[/b], [b]EasyAntiCheat off[/b] (the mod uses Harmony patches), no other mods needed, built and tested single-player, installed per client.
[size=5][b]Shout outs[/b][/size]
[b]The Fun Pimps[/b] - for 7 Days to Die itself, and for the vanilla models, icons and UI templates this mod reuses (the thrown-stone prefab, the book icon behind the summoning tomes, the video player and confirmation dialog the finale is built on).
[b]Andreas Pardeike[/b] - for Harmony. Every runtime mechanic in this mod, this release's storage fix included, is a Harmony patch.
[b]The 7 Days to Die modding community[/b] - for forum posts and open-source mods that answer the questions the XML documentation does not. The official modding API has no reference for the order its events fire in; this release's fix came down to reading that order out of the game's own code, and knowing that was the only way to find out is community knowledge.
[b]youkia96581[/b] - for the bug report that made this version exist.
[b]AI disclosure:[/b] the item icons and the finale artwork are AI-generated. Everything else - the code, the design, the mechanics and the writing - is my own.
Source code and full change history: [url=https://git.08h.ru/alex/necromants-tome-7d2d-3-2]git.08h.ru/alex/necromants-tome-7d2d-3-2[/url]
Mod page on my site: [url=https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/]alexcube.ru[/url]
My YouTube channel: [url=https://www.youtube.com/@alexcube]@alexcube[/url]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+233 -7
View File
@@ -1,8 +1,11 @@
# Книга некроманта / Necromancer's Tome (NecromancerTome) # Книга некроманта / Necromancer's Tome (NecromancerTome)
**Версия 1.0** — для 7 Days to Die 3.2. Автор: Alex Cube. *English version below — scroll past the Russian half.*
**Версия 1.2.0** — для 7 Days to Die 3.2. Автор: Alex Cube.
- Страница мода: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/ - Страница мода: 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 - Репозиторий: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
- YouTube-канал автора: https://www.youtube.com/@alexcube - YouTube-канал автора: https://www.youtube.com/@alexcube
@@ -21,16 +24,18 @@
## Прогрессия ## Прогрессия
Отдельный скилл **"Некромантия"** растёт не от опыта, а от счётчика упокоенных зомби — свой Отдельный скилл **"Некромантия"** растёт не от опыта, а от счётчика упокоенных зомби — свой
счётчик, своя механика. Пять тиров, каждый открывает часть арсенала: счётчик, своя механика. Засчитывается любое убийство, за которое игра начисляет вам опыт: добитые
ловушкой, сгоревшие, умершие от наложенного вами кровотечения, и зомби-звери наравне с
человекоподобными. Пять тиров, каждый открывает часть арсенала:
| Тир | Порог | Что открывается | | Тир | Порог | Что открывается |
|---|---|---| |---|---|---|
| Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов | | Адепт | сразу | Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов, Кровавая сфера |
| Адепт (доп.) | 20 зомби | Пространственный браслет | | Адепт (доп.) | 20 зомби | Пространственный браслет |
| Адепт (доп.) | 30 / 60 / 100 / 300 зомби | Моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё | | Адепт (доп.) | 30 / 60 / 100 / 300 зомби | Моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё |
| Подмастерье | 500 зомби | Свиток девиации | | Подмастерье | 500 зомби | Свиток девиации |
| Подмастерье (доп.) | 1400 / 1700 зомби | Моды ножа: Хватка мертвеца, Мёртвая буря | | Подмастерье (доп.) | 1400 / 1700 зомби | Моды ножа: Хватка мертвеца, Мёртвая буря |
| Ученик | 2000 зомби | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа | | Ученик | 2000 зомби | Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа, Кровавый камень |
| Некромант | 3000 зомби | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши | | Некромант | 3000 зомби | Призыв зомбомедведя, Призыв зомбоволка, Свиток банши |
| Мастер | 5000 зомби | Чёрный портальный камень | | Мастер | 5000 зомби | Чёрный портальный камень |
@@ -59,7 +64,19 @@
своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не своему спальному мешку. Прерывается любым уроном или силовой атакой раньше времени. Не
расходуется. расходуется.
- **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер - **Пространственный браслет** — силовая атака открывает личный разлом-хранилище, чей размер
растёт вместе с уровнем Некромантии. Обычная атака пока ничего не делает. растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок
прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, с тем же
круглым индикатором, что и у разбора верстака. Мир на это время обесцвечивается. Не поддаются
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира —
каждый отказ со своим сообщением. Силовая атака прерывает утаскивание и открывает хранилище.
Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака
отказывает.
- **Кровавая сфера** — заряд браслета. 500 прочности, каждая секунда утаскивания блока тратит
единицу; опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две
за раз, из крови некроманта и праха зомби.
- **Кровавый камень** — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на
2000 упокоенных и варится на химической станции из праха зомби, костей, обычной крови и крови
некроманта.
- **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо - **Консервные банки** (пустая / с речной водой / с кипячёной) — расходный цикл вместо
одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка одноразовых банок: наполняются водой, кипятятся прямо на костре без кастрюли, выпиваются, банка
возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода; возвращается пустой. Речная вода из банки может вызвать дизентерию, как обычная мутная вода;
@@ -103,12 +120,14 @@
- Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек. - Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.
- Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием, - Некоторые декоративные блоки (кровати, кулеры, картонные коробки) можно разобрать удержанием,
как верстак. как верстак.
- Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с
мёртвыми, и торгуют с ним те, кто уже не совсем жив.
## Локализация ## Локализация
**13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский, **13 языков полностью:** русский, английский, немецкий, испанский, французский, итальянский,
японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и
традиционный. Все 123 ключа `Config/Localization.csv` заполнены, пустых ячеек нет. традиционный. Все 149 ключей `Config/Localization.csv` заполнены, пустых ячеек нет.
## Установка ## Установка
@@ -120,7 +139,214 @@
## Статус ## Статус
Версия 1.0 — весь заявленный контент реализован и проходит тесты в игре. Из запланированного не Версия 1.2.0 — у Пространственного браслета появился расходник. Забор блоков теперь питается
зарядом в слоте модификаций: **Кровавая сфера** тратит единицу прочности за секунду поглощения и,
опустев, рассыпается; **Кровавый камень** с третьего грейда занимает тот же слот и не тратится
вовсе. С пустым слотом обычная атака отказывает.
В этой же версии починен счёт убийств для Некромантии. Раньше скилл рос только от убийств своей
рукой и только от человекоподобных зомби — мимо проходили зомбопёс, зомбомедведь, зомбокабан и
зомбоворон, а также всё, что убивало за вас: ловушки, огонь, кровотечение. Теперь засчитывается
всё, за что игра начисляет вам опыт. Тот же счётчик задаёт урон Ножа некроманта, так что нож
заодно перестал недобирать. Плюс торговец больше не теряет призрачность после выгрузки чанка, а
создание Крови некроманта сопровождается криком боли — оно и раньше стоило 90% здоровья, просто
молча.
Версия 1.1.0 научила Пространственный браслет забирать блоки прямо в хранилище: зажатая на блоке
обычная атака утаскивает его туда через десять секунд плюс секунда за каждый блок расстояния, с
обесцвечиванием мира на время ожидания (оно же висит и на каналах обоих порталов). Не поддаются
повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира.
Плюс все торговцы стали чёрно-белыми, полупрозрачными и матовыми.
Версия 1.0.1 закрывала первый баг-репорт с Nexus: содержимое браслета больше не пропадает после
выхода из игры (хранилище сохраняется в файле игрока, рядом с рюкзаком).
Весь заявленный контент реализован. Основное проверено в игре: заряд браслета и расход Кровавой
сферы, счёт убийств Некромантии (включая зомби-зверей, кровотечение и огонь), призрачность
торговцев, крик при создании Крови некроманта.
> **Эта сборка проверена не полностью.** Вкладка испытаний, оба первых испытания и счёт убийств
> в игре подтверждены. Ещё не проверены: **Кровавый камень** (нужен уровень Некромантии 2000 и
> химстанция), **новый порог Кровавой сферы** (переехала с уровня 1 на 20, туда же, где открывается
> Пространственный браслет), а также **награды испытаний и два новых испытания** — на Камень духов
> и Синий портальный камень. Если что-то поведёт себя не так, это ожидаемые места для сюрприза. Из запланированного не
сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в сделана только часть фирменных звуков. Текст описания для сайта (RU + EN) — в
`SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом `SITE_DESCRIPTION.html` (разметка блоков WordPress). Полная техническая история разработки и текст финала лежат рядом с модом
в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня). в `BACKLOG.md` и `FINAL_TEXT.md` — в репозиторий они не входят (спойлеры и внутренняя кухня).
---
# Necromancer's Tome (English)
**Version 1.2.0** - for 7 Days to Die 3.2. By Alex Cube.
- Mod page: https://www.alexcube.ru/7-days-to-die-moi-mody/kniga-nekromanta-necromancer-s-tome/
- Nexus Mods: https://www.nexusmods.com/7daystodie/mods/12547
- Repository: https://git.08h.ru/alex/necromants-tome-7d2d-3-2
- The author's YouTube channel: https://www.youtube.com/@alexcube
A 7 Days to Die mod about the road from ordinary survivor to necromancer - with its own
progression tree, cursed weapons, summonable creatures and a story ending.
## Premise
It all starts with a note, and reading it shows the character a blurred flashback.
Necromancy in this mod is an answer to a curse, not a side branch of crafting. Instead of falling
before the horde one day and joining it, the player learns to bend the dead to their will: to
infect zombies with madness, turn them on each other, raise their own creatures against them. Not
survival in spite of death, but power over it.
## Progression
A dedicated **"Necromancy"** skill grows not from experience but from a count of zombies put to
rest - its own counter, its own mechanic. Anything the game gives you XP for counts: kills finished
by a trap, by fire, by a bleed you applied, and zombie animals alongside the humanoids. Five tiers, each opening part of the arsenal:
| Tier | Threshold | What unlocks |
|---|---|---|
| Adept | from the start | Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits, Blood Sphere |
| Adept (extra) | 20 zombies | Spatial Bracelet |
| Adept (extra) | 30 / 60 / 100 / 300 zombies | Knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense |
| Journeyman | 500 zombies | Scroll of Deviation |
| Journeyman (extra) | 1400 / 1700 zombies | Knife mods: Dead Man's Grip, Dead Storm |
| Apprentice | 2000 zombies | Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin, Blood Stone |
| Necromancer | 3000 zombies | Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll |
| Master | 5000 zombies | Black Portal Stone |
## Arsenal
- **Spirit Stone** - a thrown stone lit with necromantic energy. It catches a single zombie: that
one switches to your side and starts attacking other zombies instead of you.
- **Scroll of Deviation** - the same effect, but stronger: on impact it wins over every zombie in
the blast area at once rather than one. Consumed on use.
- **Necromancer's Knife** - a blackened bone blade. Its damage grows with the Necromancy skill:
nearly useless in unskilled hands, lethal for a levelled player. Heals the wielder for half the
damage dealt and marks the wounded zombie as a Victim - on death it is guaranteed to leave a
special bag of ingredients.
- **Six mods for the Necromancer's Knife only** - ordinary knife mods will not fit this weapon,
and these will not fit any other: **Tears of the Dead** (2 water from every zombie killed with
the blade), **Scavenger's Feast** (2 food per corpse), **Grave's Repose** (protection from heat
and cold while the knife is held), **Dead Man's Grip** (a zombie wounded by the blade is
slowed), **Dead Storm** (the power attack hits an area and causes bleeding, for 10 health
instead of 5 and double the stamina), **Dark Sense** (every nearby zombie is marked on the
compass and map while the knife is held).
- **Necromancer's Blood** - a ritual resource: a jar takes an empty jar, any knife in hand and 90%
of your current health per portion. An ingredient for the darkest recipes - the Black Portal and
the Knife itself.
- **Victim's Skin** and **Zombie Ash** - left by a zombie marked as a Victim by the Necromancer's
Knife. Ingredients for the summoning books and for most necromantic recipes respectively.
- **Blue Portal Stone** - hold the use button for 10 seconds to teleport to your bedroll.
Interrupted by any damage, or by a power attack before the time is up. Not consumed.
- **Spatial Bracelet** - a power attack opens a personal storage rift whose size grows with your
Necromancy level. Hold the regular attack on a block and that block is pulled straight into the
rift: ten seconds up close, one more per block of distance, behind the same circular indicator a
workbench pickup uses. The world drains to black and white while it runs. Damaged blocks,
containers with anything inside, a trader's ground and indestructible things like the world's
floor all refuse, each with its own message. The power attack interrupts the pull and opens the
rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the
regular attack refuses.
- **Blood Sphere** - the bracelet's charge. 500 points of durability, one spent per second of a
pull; emptied, the sphere crumbles. Available from the very start and hand-crafted without a
workbench, two at a time, from Necromancer's Blood and Zombie Ash.
- **Blood Stone** - the same slot, but never spent at all: the endgame replacement for the sphere.
Opens at 2000 zombies put to rest and is brewed at a chemistry station from zombie ash, bones,
ordinary blood and Necromancer's Blood.
- **Tin cans** (empty / with river water / with boiled water) - a reusable cycle instead of
single-use jars: fill them with water, boil it right on a campfire without a pot, drink, and the
can comes back empty. River water from a can can cause dysentery, like any murky water; boiled
water is safe. They hold less water than glass jars.
## Pets
Summoning books raise allied creatures that fight zombies rather than the player. A pet does not
"follow" in any strict sense - it wanders on its own, and if it strays further than 32 blocks
while not in combat, it is teleported back to its owner:
- **Zombie Dog**, **Zombie Bear**, **Zombie Wolf**, **Zombie Griffin** - permanent companions. One
of each kind can be kept at a time; a power attack recalls them into the book.
- **Beetles of the Lord** - a one-shot scroll releasing a swarm. The beetles scatter over a wide
radius on their own and sting zombies; a stung zombie switches to your side just like with the
Spirit Stone. The swarm cannot be recalled and only one can be active. Consumed on use.
- **Banshee's Scroll** - single use: on opening it screams with a banshee's voice and raises a
small hostile horde next to the player. These are not allies - they are as dangerous to you as
any other zombies.
## Base defence
- **Pyramid of Spirits** - a deployable block, not a held item. While you stand in its radius it
charms any uncharmed zombie nearby on its own and sets it alight with cold purple flame, turning
it against the other zombies instead of you or your base. Its block menu toggles the effect and
shows the edge of the radius.
## The Black Portal - the story's ending
**The Black Portal Stone unlocks at the top of the progression (5000 zombies) and is the mod's
ending.** Activating it opens a confirmation dialogue, stops the game and unfolds a full-screen
finale - it finishes the story the note started on day one. The scene closes on a choice of two:
one ends the story and returns to the main menu, the other puts the player back into the world to
keep playing.
The story texts live in `Config/Localization.csv` under the `necroFinal*` keys. They are
deliberately not retold here: a README gets read before the playthrough.
## Small things
- The opening note also pauses the game and plays a short flashback.
- Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the
key, like a workbench.
- Every trader looks different: black and white, half-transparent and matte. The necromancer deals
with the dead, and the only people still trading are not quite alive.
## Localization
**13 languages, complete:** Russian, English, German, Spanish, French, Italian, Japanese, Korean,
Polish, Brazilian Portuguese, Turkish, Simplified and Traditional Chinese. All 149 keys in
`Config/Localization.csv` are filled in, with no empty cells.
## Installation
Unpack the `NecromancerTome` folder into `<game folder>/Mods/` (or into
`%APPDATA%/7DaysToDie/Mods/`) and launch the game. The mod ships Harmony libraries, so **EAC must
be turned off**.
Built for single-player: the vanilla pause only works in single-player, so in multiplayer the
story scenes play without stopping time.
## Status
Version 1.2.0 - the Spatial Bracelet now runs on a charge. Pulling blocks draws on whatever sits
in its mod slot: the **Blood Sphere** spends a point of durability per second of the pull and
crumbles once empty, while the **Blood Stone** from the third grade takes the same slot and is never
spent at all. With the slot empty, the regular attack refuses.
The same version fixes Necromancy's kill count. The skill used to rise only from kills by your own
hand, and only from humanoid zombies - zombie dogs, bears, boars and vultures never counted, and
neither did anything that killed on your behalf: traps, fire, bleeding. Now everything the game
gives you XP for counts. The same counter drives the Necromancer's Knife's damage, so the knife
stopped coming up short as well. On top of that, a trader no longer loses its ghostly look after
its chunk is unloaded, and crafting Necromancer's Blood now comes with a cry of pain - it always
cost 90% of your health, it was just silent about it.
Version 1.1.0 taught the Spatial Bracelet to take blocks straight into the vault: hold its regular
attack on a block and it is pulled in after ten seconds, plus one more per block of distance, with
the world draining to black and white for the wait (which also covers both portal channels).
Damaged blocks, containers with anything inside, a trader's ground and indestructible things like
the world's floor all refuse. Every trader is now rendered in black and white, half-transparent and
matte.
Version 1.0.1 closed the first bug report from Nexus: the bracelet's contents no longer disappear
after leaving the game (the storage is saved in the player's own file, next to the backpack).
All the announced content is implemented. The core of it is confirmed in game: the bracelet's
charge and the Blood Sphere's spending, Necromancy's kill count (zombie animals, bleeding and fire
included), the traders' ghostly look, and the cry when Necromancer's Blood is made.
> **This build has not been fully tested.** The challenges tab, its first two challenges and the
> kill count are confirmed in game. Not yet checked: the **Blood Stone** (it needs Necromancy level
> 2000 and a chemistry station), the **Blood Sphere's new threshold** (it moved from level 1 to 20,
> where the Spatial Bracelet itself unlocks), and the **challenge rewards plus two new challenges**
> for the Spirit Stone and the Blue Portal Stone. If anything misbehaves, that is where to look. Of what was planned, only part of the mod's own sound effects is missing. The description text for the website
(RU + EN) is in `SITE_DESCRIPTION.html` (WordPress block markup). The full technical history of
development and the text of the finale sit next to the mod in `BACKLOG.md` and `FINAL_TEXT.md` -
they are not part of the repository (spoilers and back-of-house).
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16 -8
View File
@@ -8,7 +8,7 @@
Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби. Пять тиров, 5000 убийств до вершины, каждый порог открывает часть арсенала: Отдельный навык <strong>«Некромантия»</strong> качается не за очки перков и не за книги, а по счётчику упокоенных зомби. Пять тиров, 5000 убийств до вершины, каждый порог открывает часть арсенала:
<table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов</td></tr><tr><td>Адепт</td><td>20 / 30 / 60 / 100 / 300</td><td>Пространственный браслет, затем моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000</td><td>Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа</td></tr><tr><td>Некромант</td><td>3000</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000</td><td>Чёрный портальный камень</td></tr></tbody></table> <table><thead><tr><th>Тир</th><th>Порог</th><th>Что открывается</th></tr></thead><tbody><tr><td>Адепт</td><td>сразу</td><td>Камень духов, Нож некроманта, Синий портальный камень, Пирамида духов, Кровавая сфера</td></tr><tr><td>Адепт</td><td>20 / 30 / 60 / 100 / 300</td><td>Пространственный браслет, затем моды ножа: Слёзы мертвеца, Пир падальщика, Могильный покой, Тёмное чутьё</td></tr><tr><td>Подмастерье</td><td>500 / 1400 / 1700</td><td>Свиток девиации, моды ножа Хватка мертвеца и Мёртвая буря</td></tr><tr><td>Ученик</td><td>2000</td><td>Призыв зомбособаки, Жуки Властелина, Призыв зомбогрифа, Кровавый камень</td></tr><tr><td>Некромант</td><td>3000</td><td>Призыв зомбомедведя, Призыв зомбоволка, Свиток банши</td></tr><tr><td>Мастер</td><td>5000</td><td>Чёрный портальный камень</td></tr></tbody></table>
<h2>Арсенал</h2> <h2>Арсенал</h2>
@@ -20,7 +20,9 @@
<li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li> <li><strong>Кровь некроманта</strong> — ритуальный ресурс: пустая банка, любой нож в руках и 90% текущего здоровья за одну порцию. Ингредиент для самых тёмных рецептов.</li>
<li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li> <li><strong>Кожа жертвы</strong> и <strong>Прах зомби</strong> — падают с зомби, помеченного ножом как Жертва. Основа книг призыва и большинства некромантских рецептов.</li>
<li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li> <li><strong>Синий портальный камень</strong> — держите кнопку использования 10 секунд, чтобы телепортироваться к своему спальнику. Любой урон прерывает переход. Не расходуется.</li>
<li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии.</li> <li><strong>Пространственный браслет</strong> — силовая атака открывает личный разлом-хранилище, размер которого растёт вместе с уровнем Некромантии. Обычная атака, зажатая на блоке, утаскивает этот блок прямо в хранилище: десять секунд вплотную и ещё по секунде за каждый блок расстояния, мир на это время обесцвечивается. Повреждённые блоки, контейнеры с содержимым, территория торговца и неразрушимое вроде дна мира не поддаются. Силовая атака прерывает утаскивание и открывает хранилище. Утаскивание питается зарядом в слоте модификаций браслета — с пустым слотом обычная атака отказывает.</li>
<li><strong>Кровавая сфера</strong> — заряд браслета: 500 прочности, каждая секунда утаскивания блока тратит единицу, опустевшая сфера рассыпается. Доступна с самого начала и делается без верстака, по две за раз, из крови некроманта и праха зомби.</li>
<li><strong>Кровавый камень</strong> — тот же слот, но не тратится вовсе: эндгейм-замена сфере. Открывается на 2000 упокоенных и варится на химической станции из праха зомби, костей, обычной крови и крови некроманта.</li>
<li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li> <li><strong>Консервные банки</strong> — расходный цикл вместо одноразовых: наполнить водой, вскипятить прямо на костре без кастрюли, выпить, банка остаётся. Речная вода из банки может вызвать дизентерию, кипячёная безопасна. Вмещают меньше стеклянных.</li>
</ul> </ul>
@@ -49,11 +51,12 @@
<ul> <ul>
<li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li> <li>Стартовая записка при открытии тоже ставит игру на паузу и проигрывает короткий флэшбек.</li>
<li>Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак.</li> <li>Часть декоративных блоков (кровати, кулеры, картонные коробки) разбирается удержанием, как верстак.</li>
<li>Все торговцы выглядят иначе: чёрно-белые, полупрозрачные и матовые. Некромант имеет дело с мёртвыми, и торгуют с ним те, кто уже не совсем жив.</li>
</ul> </ul>
<h2>Локализация</h2> <h2>Локализация</h2>
<strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 123 строки переведены, пустых ячеек нет. <strong>13 языков полностью:</strong> русский, английский, немецкий, испанский, французский, итальянский, японский, корейский, польский, португальский (Бразилия), турецкий, китайский упрощённый и традиционный. Все 149 строк переведены, пустых ячеек нет.
<h2>Установка</h2> <h2>Установка</h2>
@@ -64,7 +67,8 @@
<h2>Ссылки</h2> <h2>Ссылки</h2>
<ul> <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> <li>YouTube-канал автора: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
</ul> </ul>
@@ -82,7 +86,7 @@ Necromancy here is an answer to a curse, not a side branch of crafting. Instead
A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal: A dedicated <strong>Necromancy</strong> skill levels not from perk points and not from books, but from your zombie kill count. Five tiers, 5000 kills to the top, each threshold opening part of the arsenal:
<table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits</td></tr><tr><td>Adept</td><td>20 / 30 / 60 / 100 / 300</td><td>Spatial Bracelet, then the knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000</td><td>Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin</td></tr><tr><td>Necromancer</td><td>3000</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000</td><td>Black Portal Stone</td></tr></tbody></table> <table><thead><tr><th>Tier</th><th>Threshold</th><th>What unlocks</th></tr></thead><tbody><tr><td>Adept</td><td>from the start</td><td>Spirit Stone, Necromancer's Knife, Blue Portal Stone, Pyramid of Spirits, Blood Sphere</td></tr><tr><td>Adept</td><td>20 / 30 / 60 / 100 / 300</td><td>Spatial Bracelet, then the knife mods: Tears of the Dead, Scavenger's Feast, Grave's Repose, Dark Sense</td></tr><tr><td>Journeyman</td><td>500 / 1400 / 1700</td><td>Scroll of Deviation, knife mods Dead Man's Grip and Dead Storm</td></tr><tr><td>Apprentice</td><td>2000</td><td>Summon Zombie Dog, Beetles of the Lord, Summon Zombie Griffin, Blood Stone</td></tr><tr><td>Necromancer</td><td>3000</td><td>Summon Zombie Bear, Summon Zombie Wolf, Banshee's Scroll</td></tr><tr><td>Master</td><td>5000</td><td>Black Portal Stone</td></tr></tbody></table>
<h3>Arsenal</h3> <h3>Arsenal</h3>
@@ -94,7 +98,9 @@ A dedicated <strong>Necromancy</strong> skill levels not from perk points and no
<li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li> <li><strong>Necromancer's Blood</strong> — a ritual resource: an empty jar, any knife in hand and 90% of your current health per portion. An ingredient for the darkest recipes.</li>
<li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li> <li><strong>Victim's Skin</strong> and <strong>Zombie Ash</strong> — dropped by a zombie marked as a Victim. The basis of the summoning books and most necromantic recipes.</li>
<li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li> <li><strong>Blue Portal Stone</strong> — hold the use button for 10 seconds to teleport to your bedroll. Any damage interrupts the channel. Not consumed.</li>
<li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level.</li> <li><strong>Spatial Bracelet</strong> — a power attack opens a personal storage rift whose size grows with your Necromancy level. Hold the regular attack on a block and it is pulled straight into the rift: ten seconds up close, one more per block of distance, with the world draining to black and white while it runs. Damaged blocks, containers with anything inside, a trader's ground and indestructible things like the world's floor all refuse. The power attack interrupts the pull and opens the rift instead. The pull runs on a charge in the bracelet's mod slot - with that slot empty, the regular attack refuses.</li>
<li><strong>Blood Sphere</strong> — the bracelet's charge: 500 points of durability, one spent per second of a pull, and the emptied sphere crumbles. Available from the very start and hand-crafted without a workbench, two at a time, from Necromancer's Blood and Zombie Ash.</li>
<li><strong>Blood Stone</strong> — the same slot, but never spent at all: the endgame replacement for the sphere. Opens at 2000 zombies put to rest and is brewed at a chemistry station from zombie ash, bones, ordinary blood and Necromancer's Blood.</li>
<li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li> <li><strong>Tin cans</strong> — a reusable cycle instead of single-use jars: fill with water, boil it right on a campfire without a pot, drink, keep the can. River water from a can can cause dysentery, boiled water is safe. They hold less than glass jars.</li>
</ul> </ul>
@@ -123,11 +129,12 @@ Activating it asks for confirmation, stops the game and unfolds a full-screen fi
<ul> <ul>
<li>The opening note also pauses the game and plays a short flashback.</li> <li>The opening note also pauses the game and plays a short flashback.</li>
<li>Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench.</li> <li>Some decorative blocks (beds, water coolers, cardboard boxes) can be disassembled by holding the pick-up key, like a workbench.</li>
<li>Every trader looks different: black and white, half-transparent and matte. The necromancer deals with the dead, and the only people still trading are not quite alive.</li>
</ul> </ul>
<h3>Localization</h3> <h3>Localization</h3>
<strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 123 strings are translated, with no empty cells. <strong>13 languages, complete:</strong> English, German, Spanish, French, Italian, Japanese, Korean, Polish, Brazilian Portuguese, Russian, Turkish, Simplified and Traditional Chinese. All 149 strings are translated, with no empty cells.
<h3>Installation</h3> <h3>Installation</h3>
@@ -138,6 +145,7 @@ Built for single-player. In multiplayer the vanilla pause does not apply, so the
<h3>Links</h3> <h3>Links</h3>
<ul> <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> <li>The author's YouTube channel: <a href="https://www.youtube.com/@alexcube" target="_blank" rel="noreferrer noopener">@alexcube</a></li>
</ul> </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;
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB