Опыт - сын ошибок трудных

Каждый фатал по своей природе уникален. Они не появляются в каждой игре, а являются следствием соединения сразу нескольких обстоятельств воедино. Я запускаю карту раз по 50 в день, и любые проблемы, которые появляются «стабильно», всегда попадут на стол для препарирования, не дойдя до игроков. Единственное исключение — мультиплеер, тестировать который мы начали после провала с самой первой 88a, которая идеально работала в сингле, а в сетевой игре страдала от множественных фаталов и десинхронизаций.

Источников для проблем было великое множество. Работая с уровня встроенного игрового кода, прострелить себе ногу довольно сложно — каждая команда внутри оснащена всеми необходимыми проверками безопасности и корректности аргументов, за оочень редкими исключениями. Уронить игру в фатал весьма непросто — и обычно это достигалось не с помощью кода, а его побочных эффектов и недоработок самих Blizzard. Например, существует хорошо известный мапмейкерам фатал при удалении книги заклинаний, содержащей ауру, с мертвого юнита. При таком стечении обстоятельств игра мгновенно вылетает, и в прошлом это недоразумение подарило немало часов дебаггинга в LOD. В чем же была проблема?




Вопрос нулевой — кто виноват?

Первым делом обращаемся к крашлогу, чтобы узнать, кто виноват в вылете. Есть ненулевая вероятность, что во временном хранилище окажется ID виновника вылета, что поможет вычислить его даже в лесу. Узнав опытным путём об этом лайфхаке, я начал охоту за логами, постепенно (но очень медленно) погружаясь в их структуру. Погружение выходило очень плохо — был лишь набор фаталов с одинаковыми точками вылета, и среди данных временного хранилища отчетливо виднелись различные ID. Но что с ними делать было решительно неясно — в игре они работали как полагается, без эксцессов.



Для человека, который вообще в этом не разбирался, разбор сводился к проверке «заголовка» фатала (конкретно этот я называл QIWI-краш) и содержимого хранилища. Явно видно, что перед вылетом игра неоднократно насиловала ID A38P, а значит, на него следует обратить особое внимание. Обычно под этими ID скрывалась аура, которая вполне себе корректно работала по ходу игры и в тестах. Прикопаться было откровенно не к чему — ни под каким насилием воспроизвести ошибку не удавалось, даже добавляя эти ауры абсолютно каждому юниту.

Вопрос первый — что делать?

Учитывая отсутствие опыта и умений дебаггинга, решение было самым логичным — игнорировать проблему, пока не всплывут какие-то улики. И всего через пару месяцев страданий они всплыли! Очередной фатал оказался сдобрен комментарием, что прямо перед вылетом игрок умер, а затем добил снарядом вражину, после чего игра закрылась. Подглядев ID ауры, я спросил игрока, была ли у него такая в пике (напомню, речь про LOD), и да — он имел эту ауру в наборе, но не как обычную, а как дополнительную (5-й скилл). Этого было достаточно, чтобы начать эксперименты и обнаружить, что ошибка воспроизводится! Для этого нужно лишь обустроить ситуацию так, чтобы эта 5-я аура добавилась, пока герой-носитель ауры мертв. А в прошлом, когда мертвые получали опыт за убийства, совершенные после смерти снарядами или скиллами, это было довольно легко.

Еще несколько тестов показали, что проблеме подвержены не все, а лишь определенные ауры, причем они обязаны быть спрятанными внутри книг заклинаний. Зачем мы использовали (и используем) книжки? Это было единственной возможностью убрать лишнюю иконку с панели управления, освобождая место под другие иконки. Т.к. все эффекты вроде ускорений и замедлений работают на основе стандартных способностей, эти иконки были неизбежны. А вот у книги заклинаний есть опция «Не отображать» на панели, причем пассивные эффекты из её набора продолжают работать как положено. Соответственно, практически всегда, когда речь шла об ауре, на самом деле под капотом была книжка с этой аурой.

Фатал происходил как минимум в двух сценариях — когда книжку пытались удалить или изменить уровень ауры, что сидела внутри, пока юнит был мертв. Фактически, при удалении любой способности игра должна поэтапно снять каждый уровень этой способности, так что условие сводится к банальному изменению уровня любым образом. Тогда еще не было возможности узнать, почему так происходит, так что ограничились просто запоминанием и добавлением условий к регуляторам уровней аур — они должны были обновлять уровень только живым юнитам, или дожидаться их воскрешения.



А в чем проблема?

Сегодня, имея хоть какое-то понимание загадочных записей в крашлогах, можно раскопать чуть больше информации о крашах. Именно поэтому они так важны для определения первоисточника проблемы — если не в хранилище, то в самом коде игры можно попытаться найти зацепки.



В самом начале крашлога указана точка вылета — операция чтения по адресу 6F4790C1. 6F, как правило, является началом главной библиотеки игры — Game.DLL. В ней описана вся логика работы, все механики и прочие игровые мелочи. Для работы внутри операционной системы используется Storm.dll, который обеспечивает поддержку различных вспомогательных методов и совместимость в целом. Туда мне еще ни разу не приходилось лезть, в отличие от игрового ядра. Итак, вылет по адресу Game.DLL+0x4790C1 — так что идем туда и смотрим на операции, которые запрашивает приложение.



Очевидно, что чтение адреса 0x0000000C возможно лишь в случае, если ecx равен 0. Так и есть:



А для тех, кто не понимает сути ecx — там должна быть какая-то ссылка на структуру данных, но пришел ноль. Хотя во множестве функций игра имеет предохранители от подобных казусов, в этом конкретном месте предохранитель не поставлен, из-за чего игра и вылетает. Сама же процедура отвечает за раздачу бафа вокруг носителя ауры. И что интересно, битая ссылка возникает лишь пока юнит мертв — потому что он не должен раздавать ауры и данные, необходимые для раздачи, обнуляются. Однако при изменении уровня их всё равно приходится найти, и игра берет то, что осталось — ноль.

Дебаггинг фатала

Благодаря активному погружению в код отладка фаталов стала намного проще — в том плане, что зачастую есть понимание, какая именно процедура вылетела. Это не часто помогает исправить проблему, но хотя бы дает вектор для исследований. Раньше, если в лог не попало никаких ID, можно было лишь забить — никакой другой информации выудить бы не удалось. Однако последние месяцы меня преследовали самые разные фаталы, в разных частях карты, без единого намека на что-то общее. Одни падали при отображении объектов типа вышек в тумане войны, другие — при попытке обработать нулевой приказ, который, естественно, никто не отдавал.

Изредка репорты не включали в себя даже фатал, а состояли из какого-то нелепого бага. Например, в этом реплее нейтралы перестали появляться по всей карте с третьей минуты. Т.е. они вполне успешно появились на старте, затем еще два раза, а уже на третий — исчезли. Целиком и полностью. Внедренный дебаг показывал, что триггер, занимающийся расселением, вполне себе работал, однако не срабатывал как положено. К такому меня жизнь еще не готовила — привязанные действия просто не запускались, хотя триггер нормально отзывался на все запросы со стороны, т.е. не был отключен или сломан как-то еще.

Но всё сдвинулось с мертвой точки лишь пару недель назад, когда я вернулся к одному старому реплею. Это был репорт из игры, но суть репорта не имеет значения — та проблема была тривиальна. Что меня реально заинтересовало, так что фатал при выходе из реплея, который повторялся со 100% вероятностью. Благодаря этому можно было организовать толковый дебаг и глянуть, в чем причина ошибки — ведь не факт, что проблема появляется сугубо в реплее и не существует вне него. В прошлом уже предпринимались попытки, однако тогда я зашел в тупик. Но в этот раз я прокрастинировал собирал инфу для нового релиза, и очень хотелось закрыть как можно больше крашей и фаталов.



Ключ от всех дверей

Итак, что интересно в этом реплее? Фатал при закрытии означает, что игра пошла чистить объекты, созданные в ходе игры, и наткнулась на какой-то с некорректными данными внутри. При этом в самой игре этот объект более не запрашивается, что и позволило самому реплею существовать — после краша (а он неизбежен при сломанном объекте) они обычно не выживают. Попытки докопаться до источника ошибки не увенчались успехом — всё говорило, что это была какая-то служебная структура, и копать оттуда было просто некуда. Именно поэтому реплей так долго валялся на полке. А что еще интереснее, фатал не выскакивал, если выйти из реплея до определенного момента времени. Т.е. выход на 19-й минуте однозначно покажет фатал, а выход на 10-й завершится корректно.

В новой серии экспериментов первым делом были найдены временные рамки — проблема появляется на 12:43. Парой секунд, или даже полсекундой позже — и фатал на месте. Выйди ровно в этот момент — и всё работает как следует. Отсюда очевидное решение — брутфорсом выяснить, какое действие приводит к данной проблеме. Как же это делается в реплее?

Неподражаемый Абсо был тем самым, кто исправно поставлял мне инструменты для дебага еще начиная с LOD. Конечно, в свободное время он предпочитал писать очередной мапхак для очередного лаунчера икапа, но его светлая сторона была мне намного интереснее. Это он написал мульти-версионный загрузчик для многооконного варкрафта, сделал примитивный анализатор утечек, оптимизатор кода, а затем написал громадный JassSpy для отслеживания каждой строки исполняемого кода в игре. Всё это было незаменимо при разборе ошибок и фаталов, и, по сути, сэкономило тысячи часов работы. Но ультимативным дебагом стал редактор реплеев — крохотная прога без интерфейса для подмены контрольных сумм в данных реплея. Благодаря ей я мог добавлять дебаг прямо в код карты, на которой происходит игра, и проверять фиксы на лету, в точно тех же условиях, где была проблема.

И снова нулевой шаг — выяснить, кто виноват. У нас есть 10 героев и куча крипов на карте. Скорее всего, крипы не виноваты, поэтому начнем с героев — прямо перед секундой Х удалим всех до единого. Фатал остался на месте, хотя создать его уже никто не мог. Ладно, попытка номер два — удалим вообще всех, включая крипов. И снова неудача — фатал на месте. Достаем реплей менеджер и проверяем, что там происходило в эту секунду. К счастью, секунда выдалась скучная — буквально один-единственный паунс от Сларка, так что удаляем этот скилл с него и пробуем снова. Конечно, снова никакого эффекта — да блин, я всех юнитов удалял вообще, их абилки тем паче не могли скастоваться. Похоже, что снова тупик и ничего не сделать.

Но одна светлая голова со стороны предложила удалять героев пораньше — не за секунду до появления проблемы, а пораньше, за минуту, например. Особого резона испытывать подобную схему я не чувствовал, но на безрыбье и рак — тиммейт. Кто ж мог подумать, что это излечит фатал — выход из реплея после 43-й секунды не привел к ошибке. Через несколько итераций, приближая время удаления к проблемному, выяснилось, что при удалении на 12:37 ошибки еще нет. В эти секунды кастовалось несколько скиллов, но мне уже не требовалось редактировать каждый из них — удаление справляется с задачей намного эффективнее. Сперва я стер героев скуржей, затем — героев сентов. В первом случае фатал исчез, во втором — остался. Отсюда вывод — плохой парень играет за скуржей. Еще один вывод о том, что здесь замешан какой-то скилл с таймером около 5 секунд, мне следовало сделать там же, но я откровенно припоздал.

Перебором, удаляя по героя скуржей за раз, нашел, что фатал исчезает, как только пропадает Сларк. Еще одна игра, уничтоженная этим отродьем. Итак, в чем же провинился этот герой? У него есть паунс, диспел и ульт. А именно на 37-й секунде он включает ульт, чтобы уйти от погони или погнаться, не суть важно. А ульт длится как раз почти 5 секунд, что примерно совпадает с таймингом 12:43, когда краш становится неизбежным. Теперь настала пора дербанить ульт. Отключая строки кода из него одна за другой, нашел виновника — функция, которая восстанавливала обзор над юнитом после снятия с него тру сайтов.

Минутка внутренней механики

Каждый юнит имеет особый класс «detected» Cdet, который используется для выяснения того, видит ли игрок юнита, если тот скрыт невидимостью. Это небольшая табличка с набором данных — сколько трусайт-абилок увидели юнита от каждого игрока (индивидуальные счетчики) и какие игроки имеют право видеть юнита. Последнее является арифметической суммой индексов игроков, чьи счетчики не равны нулю. Т.е. если синий игрок видит инвиз с помощью гема и синего варда рядом, его счетчик показывает 2 — единицу добавил гем, еще одну — вард. Если юнит выйдет из аое работы варда, счетчик упадет до единицы. А т.к. синий находится в союзе с желтым, то желтый игрок тоже будет видеть юнита, которого задетектил синий, хотя собственный счетчик и равен нулю.

Реализация непробиваемого инвиза крайне проста — во время ульта со сларка постоянно сбрасывается значение суммы игроков, которые его задетектили, в 0, благодаря чему увидеть героя и развеять ульт невозможно. При этом счетчики остаются неприкосновенными — по окончанию действия ульты достаточно просто прочитать их, вычислить сумму индексов игроков и восстановить значение. Никаких побочных эффектов при этом не возникнет — игра именно так и поступает при каждом изменении счетчиков.



На скрине трусайт над юнитом имеют игроки 6 и 7, а сумма их индексов — 2^6+2^7 = 192. Данные в таблице ниже предназначены для отдельного детекта закопанных юнитов, чей инвиз предполагался быть слегка иным — его должны были видеть только конкретные типы просветов. Однако разработчики отказались от этой идеи, а поля не поправили. Впрочем, эта недоработка для нас ничего не значит. Важно то, что таблица очень компактная — всего 180 байт в длину.

Продолжаем

Итак, код, который работает с трусайтом над юнитом:

function RecountAnyDetectionForUnit takes unit u returns nothing
	local integer a=ConvertHandle(u)//адрес юнита
	local integer sum=0
	local integer i=0
	if a>0 then
		set a=GetUnitDetectedClass(u)//адрес класса Cdet
		if a>0 then
			loop
				if RMem(a+0x2C+4*i)>0 then
					set sum=sum+R2I(Pow(2,i))//сумма индексов
				endif
				set i=i+1
				exitwhen i>15
			endloop
			call WMem(a+0x14C,sum)
			call WMem(a+0x148,sum)
			set sum=0
			set i=0
		endif
		set a=GetUnitVisibilityClass(u)//класс расшаренного обзора (раньше использовалось в Track/Amplify Damage)
		if a>0 then
			loop
				if RMem(a+0x2C+4*i)>0 then
					set sum=sum+R2I(Pow(2,i))
				endif
				set i=i+1
				exitwhen i>15
			endloop
			call WMem(a+0x24,sum)
		endif
	endif
endfunction

function RemoveAnyDetectionFromUnit takes unit u returns nothing//код, который снимает детекты
	local integer a=ConvertHandle(u)//берем адрес юнита
	if a>0 then
		call WMem(a+0x148,0)//здесь внутри юнита хранится инфа о том, кто имеет расшаренный обзор
		call WMem(a+0x14C,0)//сбрасываем на 0, чтобы они никак не влияли на видимость
		set a=GetUnitVisibilityClass(u)// и на всякий случай в классе расшаренного обзора тоже меняем
		if a>0 then
			call WMem(a+0x24,0)
		endif
	endif
endfunction


Кто знаком с программированием, может, наверное, заметить одну мелкую, но крайне серьезную ошибку. Очевидно, что восстановление обзора было скопировано с функции его удаления, откуда и унаследовалась запись в поля общего обзора. Глянем еще разок на этот сегмент:



Если кто так и не понял — код с записью в блок расшаренного обзора здесь в принципе лишний, т.к. в данном случае работа идет только с детектом. Но что намного важнее, так что это то, что переменная 'a' вообще не указывает на адрес юнита — она перезаписана адресом класса детекта! А значит, этот код записывает данные куда-то не по адресу, и самое ужасное — отступ огромен. В десятичном виде 0x148 — это 328-й байт, в то время, как длина класса детекта ограничена 180 байтами. Значит, мы записываем куда-то в случайное место. Учитывая, что память обычно выделяется и используется подряд, мы наверняка залезаем в чьи-то чужие данные и ломаем их.

После исправления этой ошибки и пробного запуска фатал исчез. В реплее, где перестали появляться нейтралы, тоже воцарилась гармония — крипы продолжили населять леса как обычно.



Эта позорная ошибка существовала в коде более полутора лет — фактически, с самых первых релизов. Склонен полагать, что именно она обеспечивала большинство рандомных крашей, не имевших конкретных причин или связей между друг другом. Ошибка могла произойти в любой момент после вызова проблемной функции, а её использовал и сларк (реплей, по которому вычислил ошибку), и смоки (реплей с пропавшими нейтралами).

Вот так случайный репорт совершенно о другом вопросе помог исправить древнюю мину в коде. Копирования — зло.

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

3 комментария

avatar
кто прочитал и всё понял + в чат
avatar
set p = Sentinels[1]

Сколько же времени убивает простая лень и невнимательность.
avatar
xd
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.