Skip to the content.

Как это устроено

Техническая база проекта: что за цель, из чего собрана 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. Никаких офсетов: до полей и методов добираемся рефлексией по именам, поэтому мелкие патчи игры проект переживает.

Контент


Устройство 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():

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.tomli686-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 теги сам не отправляет, и раньше на этом спотыкались.