
Нас всех учили писать красивый код, в универе или в умных книжках рисовали идеальный мир с чистыми функциями, изящными паттернами и идеальной математикой. Но потом ты приходишь в студию и выясняется, что реальная кодовая база движка это кое‑как собранное на коленке поделие, где память течет, потоки дерутся, ос отбирает ядра в процессе выполнения, а физический движок отправляет игрока на орбиту просто потому, что кто‑то перепутал индексы осей.
Отладка багов, пожалуй, самый недооцененный инженерный навык. Ему толком не учат, его очень редко просят «осветить» на собеседновании, и еще ни одного синьора‑отладчика я в жизни не встречал. Все обучение сводится к том, что ты просто сидишь три часа и смотришь краш‑дамп, пытаясь понять, почему игра упала. Рецептов поимки и починки хитиновых товарищей тыща и один, и у каждого обязательно будет свой, поэтому смысла рассказывать о них я не вижу, но попробую рассказать о том какие собственно товарищи бывают.
Обычная очепятка
Самый обидный класс ошибок, когда твоя инженерная мысль была абсолютна верна, и алгоритм был безупречен, но пальцы бежали впереди мозга и ты просто промахнулся пальцем мимо клавиши. В коде движков это обычная история при работе со всем подрям и обычно связано с копипастой или в спешке забытой переменной:
if (update_x) pos.x = new_pos.x; if (update_y) pos.x = new_pos.y; // Дрогнула рука if (update_z) pos.z = new_pos.z;
Самое мерзкое в опечатках это наш мозг, который работает как встроенный автокорректор. Ты смотришь на этот кусок кода десять раз, и глаз просто «пролистывает» это ошибку, потому что ты знаешь, что там должно быть написано, но оно там не написано. В какой‑то момент мне надоело вылавливать такие вещи, и мы включили в проекте clang‑tidy с bugprone-* и -Wshadow на clang сборке, которые спасает от безумного количества багов с перекрытием имен переменных, и дрогнувшей руки.
class Foo { int value; public: Foo(int value) { // параметр затеняет поле class value = value; // ничего не делает, присваивает параметр самому себе } };

Логический баг
А еще код делает то, что ты написал... буквально делает, но можно написать глупость, например ошибиться на единицу (off‑by‑one), когда обновляешь кольцевой буфер событий или пытаешься удалить элемент из массива:
memmove(events + i, events + i + 1, (num_events - i) * sizeof(*events)); --num_events;
Но с такими багами хотя бы приятно работать и если есть стабильный сценарий воспроизведения, то они будут детерминированы на 100%. Ты просто садишься и шагаешь отладчиком, но у разработчиков есть пагубная привычка плодить логические баги своими руками, оптимизируя код раньше времени. Начинается всё как обычно с благих намерений: «О, давай я напишу отдельный код для фастпас или редкого случая, когда мы удаляем самый последний элемент». В итоге у тебя появляется ветка if/else, которая исполняется раз в неделю при особом положении луны и она естественно она толком не тестируется. И конечно, именно она взрывается у игрока. Чем линейнее код и чем меньше в нем редких изолированных веток, тем меньше мест, где логика может тихо хранить хитинового товарища.

Запись за границу массива
Сколько лет уже плюсам, а конца и края этим багам не видно. Вроде и логика безупречна, и алгоритм хороший, а система все равно крашится, потому что входящие данные оказались не теми, на которые рассчитывали. Завели фиксированный массив на 1024 элемента, потому что динамическая память в игре это грех, и написали простую функцию спавна:
#define MAX_PARTICLES 1024 Particle particles[MAX_PARTICLES]; uint32_t num_particles; void spawn_particle(Particle p) { particles[num_particles++] = p; }
Кто‑то обязательно заспавнит 1025-ю частицу и такой код пойдет затирать соседнюю память, и начнется ад странных крашей. Вы можете сказать, что надо срочно переписывать всё на динамические массивы? Но фиксированные пулы это предсказуемость и защита от фрагментации, в геймдеве они нужны, так что фиг вам, а не динамика.
Утечки ресурсов
Дружок предыдущего товарища, тоже сколько уж лет, а воз и ныне там, точнее баги все теже. В игровом движке утекать может что угодно: текстуры, меши, дескрипторы файлов, захваченные мьютексы, на nintendo switch в первых ревизиях рекурсивный мьюетекс не удалялся, если заходил сам в себя больше пяти раз, а их всего можно было создать 1024 штуки на процесс.
А если писать код на голых malloc и free, вылавливать утечки будет еще одним повседенным занятием. А вот найти, какой именно из пятисот вызовов выделения памяти не получил свой free это задача со звездочкой. Если думаете, что умный GC или умные указатели решают эту проблему, то нет... не решают. Они просто меняют природу утечек и вместо утечки памяти получаем утечку ссылок и какой‑нибудь забытый менеджер сцены продолжает держать ссылку на невидимый объект, а тот по цепочке держит текстуры и звуки.
В движках с этим борются тем, что никаких прямых вызовов системного аллокатора нет и все выделения проходят через кастомную обертку с инструментацией и потом можно посмотреть карту памяти, что, где кто и кого. А в момент выгрузки уровня принято сравнивать список выделений и освобождений и если что‑то осталось, значит где‑то течет.

Повреждение памяти
Если бы у дебаггинга был пьедестал, то бы повреждение памяти стояло бы на верхней ступеньке: use‑after‑free, вылет за пределы массива, запись по «дикому» указателю, повреждение заголовка блока аллокации, повторное удаление и повторное создание в размеченой области, что я еще забыл?
Ужас этих багов что причина и симптом разорваны во времени и пространстве. Функция A где‑то в модуле физики слегка затерла край соседнего массива, но физика продолжает работать, но где неправильно оторазился виджет UI при попытке показать имя игрока, и ладно если кракозябры покажет, а может и вылетать с Access Violation. И вот ты сидишь над краш‑дампом UI и не понимаешь, при чем тут вообще интерфейс.
Единственное, что спасает жизнь в таких ситуациях это AddressSanitizer (ASan) и специальные границы блоков памяти (канарейки). ASan обкладывает все аллокации защитными «красными зонами» и падает в момент невалидной записи, не давая испортить соседние структуры. А запись паттернов вроде 0xDEADBEEF в освобожденные блоки сразу дает понять в дебаггере, что ты пытаешься прочитать «труп» объекта.

Состояние гонки
Современный движок параллелит всё, что может. Физика на одном ядре, подготовка кадра на другом, стриминг ресурсов и AI на третьем, но когда два потока одновременно пытаются работать с одними данными без синхронизации, появляется он — Race Condition.
Главная подлость состояний гонки что они не живут под отладчиком, они вообще нигде не живут и стоит повесить брекпоинт или просто добавить лог, как тайминги потоков меняются, и баг бесследно исчезает, но потом ты снимаешь брекпоинт и игра снова падает. Подпорки из мьютексов выступают обычно временным решением, и по сути являются таким же бряком, откладывая гонку в другое место.
Пятничный фикс
Пятничные баги редко рождаются из фундаментальных ошибок архитектуры и их главный источник спешка, замыленный глаз и лишняя чашка кофе. Но баги вида «да я тут просто условный оператор поправил, ничего не отвалится» давно пора выносить в отдельную категорию, жаль что FridaySanitizer'a человечество так и не придумало.
// Пятница, 17:55. Фиксим редкий мигающий иконку здоровья void UIHealthBar::Update(float DeltaTime) { // "Заодно почистим каст, а то валидатор ругался..." PlayerCharacter* Player = Cast<PlayerCharacter>(GetOwningPawn()); // Раньше тут была проверка if (Player), но разработчик уверен, // что UI HealthBar существует ТОЛЬКО когда игрок жив и валиден. HealthPercent = Player->GetHealth() / Player->GetMaxHealth(); }
Код отправляется в репозиторий, билдится и уходит тестировщикам. Но в субботу утром выясняется, что когда игрок погибает, респавнится или загружает уровень через меню, UI отрисовывается на один кадр раньше, чем создается объект персонажа и указатель Player оказывается нулевым. Пятничный баг процветает не один десяток лет, пролазит через ревью и тесты и дожидается своего часа.

Гейзенбаги
Термин родился из квантовой физики и принципа неопределенности Гейзенберга, когда сам факт наблюдения за системой меняет ее состояние. В мире C++ это баги, которые живут только когда на них никто не смотрит. Прилетает от QA таска на краш при заходе в пещеру, запускаешь проект под отладчиком, доходишь до пещеры... и ничего не происходит. Все работает... делаешь это десять, двадцать раз... выключаешь отладчик, запускаешь релизный билд и ловишь падение. Сводится это обычно к неиниченной памяти и оптимизациям компилятора:
struct AttackParams { float Damage; bool IsCritical; // Забыли инициализировать в конструкторе }; void ApplyDamage() { AttackParams Params; Params.Damage = 100.0f; // Params.IsCritical содержит случайный мусор из стека if (Params.IsCritical) { // В Debug-сборке здесь всегда false из-за зануления стека. // В Release-сборке здесь может оказаться true, и крит сработает неожиданно. } }
Или таймингам многопоточности и логам. Если пытаться поймать состояние гонки между потоками добавляя логирование, то получаем искусственное замедление потоков и это смещает точку гонки, и она условно «исчезает», убираешь лог и баг возвращается. Некоторые логи потому и живут с комментарием этот пробел не удалять.
void MeshLoader::OnAsyncLoadComplete(Mesh* LoadedMesh) { log("Mesh loaded: %s\n", LoadedMesh->GetName()); // Этот лог не удалять!!! RenderQueue::Enqueue(LoadedMesh); }
Подключение отладчика или включение специальных дебаг‑флагов меняет размер заголовочных файлов и структур данных (например, добавляются дебажные итераторы в std::vector или дополнительные поля валидации в аллокаторе), в результате сдвигаются адреса в памяти, и повреждение памяти, которое раньше затирало важные данные, начинает затирать безвредную «подушку безопасности» (guard bytes), маскируя проблему.
Фичебаг («It's not a bug, it's a feature»)
А еще есть ситуации, когда сбой в математике или логике создает настолько гениальный геймплейный опыт, что разработчики решают ничего не чинить, а просто переименовывают баг в «механику».
Комбо в Street Fighter II изначально имело возможность отменять анимацию одного удара другим, но это был баг таймингов анимации. Продюсер Норитаки Фунамидзу заметив это, решил, что поймать тайминг слишком сложно, и оставил и родился весь жанр современных файтингов.
Агрессивный Ганди в Civilization. Из‑за переполнения 8-битного целого (unsigned integer) агрессия Ганди, снижаясь ниже нуля при принятии демократии, сбрасывалась в 255. Миролюбивый лидер превращался в ядерного психопата. Это на самом деле байка, и ничего такого не было, но миф этот стал настолько известным, что разработчики пятой части внесли его логику игры и сделав миф реальностью.
Распрыжка (Bunny Hopping) в Quake. Ошибка в векторе сложения скоростей при прыжке и повороте камеры позволяла разгоняться до скоростей истребителя, что позволяло вытворять очень веселые трюки на уровнях. В принципе если баг делает игру веселее, то его не фиксят, а полируют и отдают маркетологам, на этом построена целая серия игр Saints Row, где QA получали премии за экплуатацию разных багов, которые потом делали элементами механик самой игры.

Эффект Бабочки (The Butterfly Effect / Floating‑Point Math)
Баги, возникающие из‑за ограниченной точности чисел с плавающей запятой, когда игрок уходит далеко от центра координат. Чем дальше игрок от центра, тем меньше точности остается на дробную часть и на удалении порядка 100км, точность шага составляет несколько миллиметров, в итоге моделька начинает непрерывно «вибрировать» и эпилептически дергаться при ходьбе, пока её не выплюнет за пределы мира.
Или приводит к артефактам генерации, как в Minecraft (Far Lands), где на расстоянии в 12.5 миллионов блоков от спавна погрешность float при генерации шума Перлина становилась настолько огромной, что ландшафт превращался в гигантские дырявые сырные стены.
Спагетти‑Зависимости
Забавные баги, когда игра падает только при условии, что вы открыли дверь, удерживая в руках определенный предмет, и обязательно под углом в 45 градусов. В TF2 есть знаменитый городской миф про текстуру кокоса внутри файлов игры, без которой движок Valve Source просто отказывается запускаться. В файлах игры реально лежит текстура coconut.vtf, это VTF‑файл с реалистичным изображением кокоса, и его происхождение тянется к апдейту Love&War 2014 года, и судя по всему, остался как неиспользуемый ассет с тех времён. Легенда про то, что удаление файла ломает запуск игры, разрослась в основном благодаря шуточному комментарию под оригинальным постом в духе «без понятия, кто это сюда положил, но когда я удалил файл, игра перестала запускаться», который многие приняли за настоящий комментарий разработчика из исходников.
Или кейс из Lineage 2, когда игроки не могли зайти на локацию, потому что в его инвентаре лежал квестовый предмет из 2009 года, у которого идентификатор совпада с новым типажом анимации для дракона.
Инверсия приоритетов
Баг многопоточных движков, из‑за которого игра начинает лагать при работе низкоприоритетных потоков. Низкоприоритетный поток (например, фоновый стриминг аудио) захватывает мьютекс, потом высокоприоритетный поток пытаются взять этот же мьютекс и засыпает, уступая время, но в этот момент поток со средним приоритетом (например, обсчет AI) не дает низкоприоритетному потоку завершить работу и отпустить мьютекс. В итоге главный поток ждет аудио, аудио ждет AI, а игрок смотрит как это все еле шевелится, если вообще шевелится.
Баги плавающего шага времени (Variable Delta Time)
Плата за стремление к разблокированному фреймрейту. Вы связываете физику или перемещение с тем dt, который прошел между кадрами.
// Если кадр просел с 60 FPS (16мс) до 2 FPS (500мс) из-за загрузки position += velocity * dt; // dt стал гигантским
И за один длинный кадр (например, когда игра «лагнула») персонаж пролетает насквозь через трехметровую стену, потому что физический коллайдер просто перескочил ее в один шаг.
Что я забыл?
В конечном счете, хороший код игровой системы отличается от плохого не отсутствием багов, потому что ошибки делают все. Он отличается тем, насколько легко его отлаживать, связностью систем, фиксированным границами модулей, работой с памятью. Все врут, и даже код... особенно код... никогда не верьте коду на слово.

