Как это устроено
Техническая база проекта: что за цель, из чего собрана DLL, какие места игры она трогает и почему именно эти. Всё проверено по декомпиляции установленной копии игры, а не взято из общих знаний.
Для игроков есть описание на главной странице проекта — там коротко и по делу.
Оглавление
- Как это устроено
Цель и следствия
| Параметр | Значение |
|---|---|
| Игра | Terraria 1.4.5.8 (в PE-ресурсе версия отстала и показывает 1.4.5.6) |
| PE | PE32, Machine 0x014C (i386) |
| CLR header | ILONLY \| 32BITREQUIRED → процесс всегда 32-битный |
| Рантайм | .NET Framework 4.x |
| Графика | XNA 4.0 → Direct3D 9 (на Vista+ создаётся D3D9Ex) |
| Типов в сборке | 2851 |
Следствия. DLL собирается только под i686-pc-windows-msvc, рендер-хук —
D3D9. Никаких офсетов: до полей и методов добираемся рефлексией по именам,
поэтому мелкие патчи игры проект переживает.
Контент
Content/Fonts/*.xnb— пятьSpriteFont, нам нуженMouse_Text.Content/Images/— около 13900 файлов, из нихUI/— 210.- Заголовок XNB:
58 4E 42 77 05 80→ платформаw, версия 5, флаг0x80= LZX-сжатие. Отсюда зависимостьlzxd. - Звуки —
Content/Sounds/*.xnb(SoundEffectReader), музыка отдельно вWave Bank.xwbи нас не касается.
Устройство DLL
Три потока, у каждого своя роль. Путаница между ними — источник почти всех падений, поэтому граница проведена жёстко.
| Поток | Что делает | Файлы |
|---|---|---|
| Рабочий | хоткеи, автомат рыбалки, конфиг, чтение состояния игры | app.rs, fishing.rs, game.rs |
| Игровой | применяет решения: нажатия, раскладка, чат, звук, подсказки | input.rs — из детура Player.ItemCheck |
| Рендер | рисует панель | overlay/ — из детура Main.DrawCursor, запасной путь Present |
Игровой и рендер — физически один и тот же поток игры, но моменты кадра у них разные, поэтому в коде они разведены.
Общее состояние — overlay/state.rs: мьютекс, который берут короткими
кусками. Рабочий поток пишет туда показания игры, панель — переключатели.
Правило потоков
Любой вызов, который что-то в игре меняет, идёт с игрового потока. Читать поля с рабочего можно, менять — нельзя.
Проверено дорого. Player.QuickStackAllChests(), вызванный с рабочего
потока, падал по нулевому указателю каждый раз: QuickStacking ведёт весь
разбор в общих статических буферах (NearbyChests._scratch,
inventoryItemsScratch, пул DestinationHelper) и переставляет предметы
прямо в player.inventory и Chest.item, без единого замка. Сама игра
зовёт это из Main.DrawInventory, то есть строго из своего кадра.
Как устроен обмен: рабочий поток ставит заявку атомиком, игровой её
исполняет в ближайшем кадре и снимает флаг. Так сделаны нажатие
(COMMAND), раскладка по сундукам (QUICK_STACK), строки в чат (CHAT)
и звук (SOUND). В простое проверка стоит одного load, ни одного
обращения к CLR.
Механика рыбалки
Состояния поплавка
Из Projectile.AI_061_FishingBobber():
ai[0] == 0— поплавок заброшен и работает;ai[0] >= 1— подтягивание к игроку (== 2— момент подсечки);ai[1] == 0— простой;localAI[1]при этом счётчик накопления поклёвки: растёт наrand(1,3) + fishingLevel/30за тик, при> 660сбрасывается и вызываетсяFishingCheck();ai[1] < 0— активная поклёвка, окно подсечки. Задаётся какrand(-240,-90) - fishingLevel, растёт наrand(1,5)за тик;ai[1]дорос до нуля — поклёвка упущена.
Projectile.wet отличает «летит» от «в воде» — на этом построены три
состояния поплавка в панели.
Что клюнуло, известно ДО подсечки
Projectile.SetFishingCheckResults() на поклёвке пишет:
ai[1] = rand(-240, -90) - fishingLevel; // окно подсечки
localAI[1] = fisher.rolledItemDrop; // id улова
localAI[2] = fisher.playerFishingConditions.BaitItemType;
а для вражеского спавна — localAI[1] = -fisher.rolledEnemySpawn.
Итог: localAI[1] > 0 — id предмета, < 0 — минус id NPC. Это тот же
источник, из которого зелье сонара рисует всплывающий текст. Фильтр до
подсечки полностью реализуем.
Пропуск ничего не стоит
Player.ItemCheck_CheckFishingBobber() тратит наживку внутри
ConsumeBait, и только на пути успешной подсечки. Не подсекли — наживка
цела. Поэтому фильтр бесплатный: мимо проходит всё, что не нужно, а запас
наживки тратится только на нужное.
Оттуда же: цикл идёт по Main.projectile[0..1000] с условием
active && owner == whoAmI && bobber, и наличие поплавка запрещает новый
заброс — поплавок у игрока всегда один.
Наживка ищется как в Player.Fishing_GetBait(): скан inventory[i] на
stack > 0 && bait > 0. Той же логикой ловим момент «наживка кончилась».
Автопитьё
Пять ячеек, game::POTIONS — id предмета и id баффа:
| Предмет | id | Бафф | Длительность |
|---|---|---|---|
| Fishing Potion | 2354 | Fishing (121) |
28800 |
| Sonar Potion | 2355 | Sonar (122) |
28800 |
| Crate Potion | 2356 | Crate (123) |
28800 |
| Ale | 353 | Tipsy (25) |
7200 |
| Sake | 2266 | Tipsy (25) |
14400 |
Tipsy попал сюда не как еда, а как рыболовный бафф:
Player.GetFishingConditions() даёт за него +5 к силе рыбалки —
if (FindBuffIndex(25) != -1) num += 5;, столько же, сколько за ловлю сидя
или стоя в воде. Платится за это четвёркой защиты (Player.UpdateBuffs,
ветка buffType == 25).
Эль и сакэ — еда (Item.DefaultToFood), а не зелья, но механика одна:
Player.AddBuff(buffType, Item.buffTime) и stack--. Бафф у них общий,
поэтому при двух включённых ячейках пьётся только эль: сакэ на том же тике
видит Tipsy уже висящим. Длительность берём у самого предмета, а не из
таблицы, — её игра держит в Item.buffTime.
Ровно так же устроено и быстрое питьё самой игры,
Player.QuickBuff(): AddBuff, stack--, TurnToAir() на нуле и звук
Item.UseSound. Анимации у него нет — она принадлежит предмету в руке,
а в руке удочка; остаётся только звук, и его мы повторяем (SoundID.Item3,
у всех пяти предметов UseSound именно он).
QuickBuff перебирает inventory[0..58], то есть и патронные слоты:
у эля ammo = 353, он живёт там на равных. Поэтому и мы ищем питьё в них
(QUICK_BUFF_SLOTS), иначе панель показывала бы «нет в инвентаре» при
полном запасе.
Пьём только пока рыбалка действительно идёт: переключатель включён и точка заброса уже зафиксирована. Переключатель при этом остаётся главным флагом — просто сам по себе он ничего не пьёт, иначе бафы горели бы, пока игрок ищет место. Пока рыбалка не пошла, в строке стоит приписка «ждёт заброса».
Ввод и детуры
Реальный ввод при потере фокуса игра игнорирует, поэтому SendInput
неприменим. Нажатие выставляется прямо в поле Player.controlUseItem
внутри игрового кадра.
Детур Player.ItemCheck() — метод публичный, без аргументов,
вызывается каждый кадр и читает controlUseItem уже после нашей врезки.
Адрес JIT-кода берётся через RuntimeMethodHandle.GetFunctionPointer().
Экземплярные методы .NET на x86 передают this в ECX, а стабильного
extern "thiscall" в Rust нет. Поэтому детур — голая функция: сохраняет
все регистры, зовёт обработчик по cdecl и прыгает на трамплин. Вопрос
соглашения о вызове снимается целиком.
Клик эмулируется парой тиков press/release. Границу тика считаем сами,
по указателю this: метод вызывается ровно раз на игрока за тик, порядок
игроков в тике постоянен, значит тик начинается заново каждый раз, когда
снова приходит тот же this, с которого он начался. В одиночной игре игрок
один, и каждый вызов — новый тик.
Так вышло не сразу, и обе прежние попытки были ошибками.
Сначала границей служил счётчик кадров из хука Present. Свёрнутая игра
обновляется, но не рисует, счётчик там замирает, и граница не наступала
никогда: при свёрнутом окне не проходил ни один заброс — автомат ставил
команду, ждал 1.2 с и снимал её по таймауту.
Потом номер тика спрашивался у игры — Main.GameUpdateCount. Работало,
но убивало процесс.
Почему детур не должен трогать CLR
Это стоило одного тихого падения и разобрано по расшифрованному стеку
(публичные символы clr.dll с сервера Microsoft):
JIT_NewArr1 -> AllocateArrayEx -> GCHeap::Alloc
-> gc_heap::trigger_gc_for_alloc -> GCHeap::GarbageCollectGeneration
-> gc_heap::mark_phase -> GCToEEInterface::GcScanRoots
-> Thread::StackWalkFrames -> GcStackCrawlCallBack
-> EECodeManager::EnumGcRefs -> GCHeap::Promote
-> gc_heap::mark_object_simple <- чтение по нулю
Сборка мусора пошла искать корни в стеке игрового потока, в каком-то кадре приняла мусор за ссылку на объект и умерла, пытаясь её пометить.
Виноват детур. Он стоит на первых байтах Player.ItemCheck, а голая заглушка
кладёт на стек десять двойных слов (pushad, pushfd, push ecx). Сборщик
определяет метод по адресу возврата, декодирует его GC-информацию для смещения
ноль и ищет ссылки относительно ESP — а тот съехал на сорок байт.
Само по себе это опасно только в момент сборки мусора. Но MethodInfo.Invoke
и FieldInfo.SetValue сами выделяют память, то есть каждый вызов рефлексии
отсюда мог запустить сборку ровно тогда, когда наш кривой кадр лежит на стеке.
Не гонка, а рулетка: шестьдесят прокруток в секунду.
Отсюда правило: детур не обращается к CLR ни на холостом ходу, ни на
нажатии. Номер тика считается по указателю, нажатие ставится записью байта
в объект игрока (input::set_use_item_on), смещение поля вычисляется один раз
на живой игре и там же проверяется обратным чтением через рефлексию. Не
сошлось — остаёмся на рефлексии.
Остались редкие обращения: раскладка по сундукам, строка в чат, звук. Они
идут единицами в минуту, а не десятками в секунду, но по-хорошему им место
в хуке Present: туда игра приходит через P/Invoke, поток в вытесняющем
режиме и с честной переходной рамкой, и стек разбирается нормально.
Чтобы следующее падение разбиралось не по адресам, а по именам, ловушка
пишет рядом с логом минидамп (piscatio-crash-N.dmp, до двух за сессию).
Детур Main.DrawCursor() нужен не ради данных, а ради момента: игра
зовёт его сразу после spriteBatch.End(), когда интерфейс уже на экране,
а курсор ещё не нарисован. Панель, нарисованная здесь, ложится поверх
интерфейса и под курсор — своего курсора рисовать не надо.
Работа при свёрнутом окне. Кадры свёрнутая игра не рисует вовсе (ни
Present, ни EndScene), поэтому логика рыбалки обязана жить
в managed-детуре, а не в рендер-хуке.
Настройки игры при этом не трогаем. Прежние версии снимали
Main.ThrottleWhenInactive, считая его просто сном в 20 мс на кадр. Это
оказалось неверно и вредно — разобрано по декомпиляции Main.DoUpdate:
if (FrameSkipMode == Off || FrameSkipMode == Subtle) {
base.IsFixedTimeStep = ThrottleWhenInactive && !base.IsActive;
}
base.InactiveSleepTime = ThrottleWhenInactive ? 20 мс : TimeSpan.Zero;
Поле управляет шагом времени: игра сама переходит на фиксированный шаг, когда теряет фокус, — именно чтобы мир не убегал. Снимая его, мы этот переход отключали, и цикл разгонялся до тысяч тиков в секунду: замерено 2408 против 60, то есть игровые сутки за полминуты реального времени, со всеми спавнами и погодой.
А сон симуляцию не замедляет. В Game.Tick XNA сначала спит, затем делит
накопленное время на targetElapsedTime и прогоняет столько Update,
сколько накопилось. Выходит ровно 60 обновлений в секунду; падает только
частота кадров — которых у свёрнутой игры и так нет.
Заодно ушла порча чужих настроек: Main.SaveSettings кладёт
ThrottleWhenInactive в config.json игры, так что наша правка оставалась
у игрока навсегда — свёрнутая игра разгонялась уже без всякой DLL.
Выдержка тиков (input::pace) осталась страховкой. Если у игрока
в config.json игры ThrottleWhenInactive: false — хоть от старых наших
версий, хоть выставленный руками, — ограничителя у свёрнутой игры нет,
и держим её мы: досыпаем до 16.7 мс от прошлого тика. Разрешение системного
таймера на это время поднимается до миллисекунды, иначе Sleep округлился
бы до 15.6 мс и вместо шестидесяти тиков вышло бы тридцать два. Когда игра
держит себя сама, срок к моменту проверки уже истёк и сна не происходит
ни разу.
Заброс при живом поплавке невозможен. ItemCheck_PullFishingBobbers
возвращает false, если у игрока есть активный поплавок, — новый снаряд
не создаётся. Зато то же нажатие ставит поплавку ai[0] = 1f, то есть
подсекает его: он летит обратно к игроку и Kill()-ится при касании.
Наживка при этом не тратится — она списывается только внутри
if (projectile.ai[1] < 0f && ...), то есть лишь при настоящей поклёвке.
На этом и держится спасение застрявшего поплавка: нажатие убирает его,
а следующий тик застаёт пустую воду и забрасывает заново обычным путём.
Сам по себе он не исчезнет никогда — AI_061_FishingBobber каждый кадр
переставляет timeLeft = 60, пока удочка в руке.
Чего не хватает
Колесо мыши уходит и в игру: PlayerInput.ScrollWheelDelta считается
в UpdateInput на фазе Update, и там же его съедают
Player.HandleHotbarControls и Main.DoScrollingInInventory — оба
вызываются раньше нашего единственного детура на этой фазе. Чтобы
погасить, нужны детуры на них с пропуском оригинала.
Оверлей
Панель нарисована родными текстурами игры, а не своей графикой:
окна и кнопки — девятичастная нарезка UI/PanelBackground, ячейки
и переключатели — целые спрайты. Поэтому цвета в коде почти всегда белые:
свой цвет у графики уже внутри.
| Что | Откуда |
|---|---|
| Фон окна | UI/PanelBackground, цвет Color(63, 82, 151) * 0.7 из UIPanel |
| Обводка | UI/PanelBorder цветом края Chat_Back — (18,18,38) через Color(200,200,200,200), как в разговоре с НПС. Чёрная рамка UIPanel рядом с игровым интерфейсом выглядит жирной |
| Строка | тот же PanelBackground заливкой InnerPanelBackground — у родного уголки срезаны на один пиксель, и строки выглядели вырубленными по линейке |
| Ячейка предмета | Inventory_Back, отметки — Inventory_Back15 зелёным и красным |
| Крестик «пропускаю» | CoolDown — им же игра перечёркивает неразблокированное в меню дублирования (ItemSlot.Draw, контекст 34 CreativeInfiniteLocked): по центру ячейки, 32 пикселя на 52-пиксельную, вполсилы |
| Строка поиска | как UIWrappedSearchBar: высота 24, кнопка Button_Search, поле — панель с заливкой и обводкой Color(35, 40, 83) |
| Фокус поля | не картинка поверх, а перекраска обводки в Main.OurFavoriteColor (255, 231, 69) |
| Шрифт | Content/Fonts/Mouse_Text.xnb, текст с чёрной обводкой в четыре прохода, как ChatManager.DrawColorCodedStringWithShadow |
Размер панели идёт от Main.UIScale, только на десятую плотнее (DENSITY).
Ячейки предметов — ровно инвентарные, 52 * Main.inventoryScale, без
уплотнения: иначе иконки не совпали бы с игровыми.
Текст в строках опущен на пару пикселей от математической середины: видимая часть шрифта игры несимметрична, верхние выносные длиннее нижних, и по цифрам ровный текст на глаз казался завышенным.
Иконки переключателей
Пара картинок на строку, размер вписывается в квадратную кнопку с полем
по краям. Выключенные состояния, которых в игре нет, делаются из включённых:
icons::grayscale обесцвечивает, icons::darkened притемняет вычитанием
(не умножением — множитель растянул бы разницу между светлым и тёмным).
Картинка может быть анимированной. У анимированных предметов в файле
лежит вертикальная лента кадров, а скорость игра держит в
Main.itemAnimations — например RegisterItemAnimation(521,
new DrawAnimationVertical(6, 4)): шесть тиков на кадр, четыре кадра.
Лента режется на кадры, они кладутся в атлас подряд, и Layout::toggle
выбирает нужный вычитанием из Knob.on. Счёт идёт по кадрам отрисовки —
они же кадры игры, поэтому скорость выходит ровно как в инвентаре.
Лента бывает и у неподвижных картинок: Main.InitializeItemAnimations
проходит по ItemID.Sets.IsFood и вешает на каждую еду
DrawAnimationVertical(int.MaxValue, 3) — три кадра, смена никогда. У эля
и сакэ это кружка, наполовину выпитая и поставленная на стол. Числу кадров
из Main.itemAnimations можно верить и здесь: берём верхний кадр, как берёт
его ячейка инвентаря.
| Строка | Включено | Выключено |
|---|---|---|
| Авторыбалка | Item_521 эссенция ночи, живая лента |
её же первый кадр без цвета |
| Сундуки | UI/ChestStack_1 |
UI/ChestStack_0 |
| Врагов подсекать | Buff_162 единорог |
Buff_276 скакун, без цвета |
| Автопитьё | Item_5042 кофе |
он же без цвета |
| Режим списка | Item_2373 катушка |
она же притемнённая |
Подсказки
Подсказку предмета показывает сама игра: Main.DisplayAndGetFakeItem
заводит очередь, Main.HoverItem даёт предмет, рисует DrawPendingMouseText
уже после нас. Свои текстовые подсказки идут через
Main.MouseTextNoOverride — ей же подписаны кнопки инвентаря.
Точки в CLR
Всё берётся рефлексией по именам. Ничего критичного не разрешается через
? без запасного пути там, где промах стоит одной функции, а не всей DLL.
| Что | Зачем |
|---|---|
Main.myPlayer, Main.player, Main.projectile |
состояние мира |
Main.ThrottleWhenInactive |
работа при свёрнутом окне |
Main.drawingPlayerChat |
открыт чат — хоткеи молчат |
Main.FishDropsDB |
список ловимого — 128 предметов |
Main.itemAnimations |
у анимированных иконок брать верхний кадр |
ItemID.Sets.IsFishingCrate |
счётчик ящиков |
Player.ItemCheck, Main.DrawCursor |
адреса под детуры |
Player.controlUseItem |
нажатие |
Player.QuickStackAllChests |
раскладка по сундукам |
Player.HeldItem, Item.fishingPole |
удочка ли в руке |
Player.AddBuff, Item.buffTime |
автопитьё, см. Player.QuickBuff |
Item.netDefaults, Item.AffixName, Item.questItem |
имена и квестовая рыба |
Lang.GetNPCNameValue |
имена спавнов |
Language.ActiveCulture, GameCulture.LegacyId |
язык интерфейса |
Main.chatMonitor, IChatMonitor.NewText |
строки в чат |
SoundEngine.LegacySoundPlayer, LegacySoundPlayer.PlaySound, SoundID.BestReforge |
звуки: квестовая рыба, кнопки панели, строка в чат, глоток автопитья |
PlayerInput._originalMouseX/Y |
курсор в сырых экранных пикселях |
Main._uiScaleUsed |
масштаб интерфейса |
Ловушки рефлексии
Три грабли, на которых проект уже спотыкался. Если добавляете вызов — проверьте их сразу.
1. Перегрузки. Type.GetMethod(String) на перегруженном имени бросает
AmbiguousMatchException, то есть метод просто «не находится». Так вышло
с Main.NewText (две перегрузки) и SoundEngine.PlaySound (четыре).
Обход — найти неперегруженного тёзку: IChatMonitor.NewText и
LegacySoundPlayer.PlaySound оба единственные в своих типах.
2. Свойства — не поля. Player.selectedItem, Player.HeldItem,
Language.ActiveCulture, LegacySoundStyle.Style — свойства.
Через GetField их не достать, нужен геттер get_Имя.
3. Значения по умолчанию не подставляются. У метода могут быть
необязательные параметры, но рефлексия их не заполняет: передавать надо
все. И типы точные — стандартный биндер сужающих преобразований не делает,
Int32 вместо Byte он не пропустит (отсюда Var::byte с VT_UI1).
Ловушка падений
Игра дважды умирала молча: ни окна ошибки, ни строчки в логе. Тихая смерть без диалога — это нативное нарушение доступа, и по обычному логу такое не отследить, потому что до записи дело не доходит.
src/crash.rs ставит векторный обработчик исключений: он срабатывает
до раскрутки стека и успевает записать код, адрес и модуль. Управляемые
исключения .NET (0xE0434352) пропускаются молча — их в игре тысячи
и они штатные.
По одному адресу в clr.dll не видно, чей вызов туда зашёл: рефлексию
зовут оба потока. Поэтому каждая точка входа в CLR помечается через
crash::Step — отметка снимается сама при выходе, а падение печатает обе:
ПАДЕНИЕ: код 0xC0000005 по адресу 0x… в clr.dll, чтение по 0x00000000
| игровой поток: строка в чат, рабочий: чтение поплавка
Добавляя новый вызов в CLR, ставьте отметку рядом — иначе следующее падение снова окажется безымянным.
Язык интерфейса
src/lang.rs. Язык панели — язык игры: русский, если у игрока русский,
иначе английский. Спрашивается Language.ActiveCulture и её LegacyId
(русский шестой), перечитывается раз в пару секунд — игрок меняет язык
прямо в настройках, не выходя из мира.
Переводится то, что видит игрок: подписи панели, подсказки, статистика, строки в чат. Лог остаётся русским — он для разработки.
Строки с подстановкой хранятся шаблонами с {} и собираются lang::fill:
format! тут не годится, шаблон приходит из таблицы, а не из исходника.
Сообщения в чат
Пишутся языком самой игры: [c/RRGGBB:текст] красит кусок, [i:id]
вставляет предмет — с иконкой и той же подсказкой, что в инвентаре.
Разбирает это ChatManager.ParseMessage, то есть всё оформление на игре.
Ярлык красится двумя тегами, а не одним. В ChatManager.Regexes.Format
текст тега — (?<text>.+?) до первой неэкранированной ], поэтому
закрывающая скобка ярлыка обрывает тег и остаётся некрашеной. Она уезжает
в начало второго тега вместе с тире:
[c/2A2A2A:[Чёрный список][c/2A2A2A:] —] пропущен предмет Камбала [i:2290]
Сообщение никуда не уходит: chatMonitor — местный список строк, его видит
только сам игрок.
Конфиг
piscatio.toml рядом с DLL. Пишется вручную (Config::to_commented_toml),
а не toml::to_string_pretty: сериализатор не умеет комментарии, а файл
должен объяснять каждую строку. Панель пересохраняет его на каждом
переключении, поэтому текст восстанавливается целиком.
Что написали, то и должно читаться обратно — на это есть тест
commented_toml_reads_back. Ручной сериализатор без него — мина.
Списки, которые могут вырасти, читаются снисходительно: potions пришёл
с тремя ячейками, а стал с пятью, и строгий массив объявил бы старый файл
битым целиком — вместе с фильтром улова, который игрок собирал руками.
Поэтому potion_flags берёт список любой длины: лишнее отбрасывает,
недостающее гасит (тест old_three_slot_potions_still_load).
Пробник и инструменты
Пробник (probe) — отдельный cargo-проект: создаёт настоящий
D3D9-девайс, подключает src/overlay/*.rs по #[path] и пишет кадр в PNG.
Пёстрый фон под панелью — чтобы была видна прозрачность; --bg заменяет
его ровным, для снимков.
probe.exe --size 1920x1080 --cursor 960,300 --out out\frame.png
Полезные флаги: --click X,Y (нажать перед снимком, ими открываются
вкладки), --crop x,y,w,h и --zoom N, --ui-scale N (проверять стоит
хотя бы на 1.0 и 1.5), --wheel N, --search «текст».
Смотрелка ассетов (dump) разбирает XNB и кладёт PNG:
--raw — с настоящей прозрачностью, --sheet — контактный лист каталога,
--sounds — короткие звуки в WAV.
Декомпиляция. Рабочий путь — свой мини-инструмент на пакете
ICSharpCode.Decompiler: dec.exe Terraria.exe <каталог> пишет список
типов, dec.exe Terraria.exe <каталог> <Полное.Имя.Типа>... — их исходники.
Terraria.Main разбирается за семь секунд, так что искать по коду игры
дешевле, чем угадывать. dnSpy в консоли на этой машине нерабочий.
Сборка и релиз
cargo build --release
Цель одна и зашита в .cargo/config.toml — i686-pc-windows-msvc.
build.rs кладёт в DLL VERSIONINFO из Cargo.toml, поэтому свойства
файла и заголовок панели не разъезжаются с версией.
Пересобрать DLL, пока она загружена в игру, нельзя: либо закрыть игру, либо переименовать занятый файл — Windows это позволяет.
CI — .github/workflows/release.yml. На main и в pull request
гоняются fmt, clippy, тесты и сборка; DLL прикладывается артефактом.
Релиз выкладывается по версии из Cargo.toml: как только в main
приезжает коммит с новой версией, для неё заводится тег и релиз.
Тег руками пушить не нужно — git push теги сам не отправляет, и раньше
на этом спотыкались.