Как один недочет полгода заруинил

На протяжении всего бета-теста карты появлялись репорты о фаталах на ровном месте. Они происходили у одного игрока, редко — двух. Произойти вылеты могли на любом этапе, преимущественно — во время загрузки карты, но и по ходу игры варкрафт не стеснялся молча закрыться. Никаких общих черт у вылетающих игроков не было — ни ОС, ни варкрафт, я и сам ловил аналогичный краш. Схожим было лишь одно — это была не первая игра на карте. Её как минимум начинали до этого, может, полноценно, а может, и для того, чтобы написать !rmk.

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




За это время мы успели посидеть и слезть с 1.27, поэтому сперва камни полетели именно в неверную версию варкрафта. «Может, это очередной баг близзардов?», молился я. Для тех, кто не в курсе:

В патче 1.27 были допущены какие-то ошибки, приводящие к смещению стека памяти во время игры. На практике это означало, что часть пойманных фаталов и проблем были вызваны вовсе не картой, а проблемами патча. Плюс недокументированные обновления Storm.dll.

Но нет, на 1.26 фаталы не прекратились. Забавно, что они шли волнами — иногда целыми днями люди играли без проблем, а иногда и начать одну катку было тяжело, т.к. кто-нибудь отлетал во время загрузки. Это затрудняло диагностику, но мы не сдавались.

Первые шаги — отключить «излишний» функционал мемхака, попытка сузить круг подозреваемых. Может, неверно сделанный хук со временем приводил к аналогичному смещению стека? Отключаем хук-функции и выпускаем версию с урезанным функционалом. Но нет, буквально в первых же играх проблема повторяется. Пробуем отключить другую функцию и снова релизим. День проходит хорошо, на следующий — снова знакомый фатал. Это было просто неописуемо больно.



Откатывать весь мемхак? Это отказ от всех фишечек и возможностей, появившихся в последних версиях, и признание недееспособности идей Д2 на этом движке. Откатывать частями? Да вот только как понять, какая именно из частей так повлияла на игру? Хуки в памяти остались вне подозрений, все они были переписаны и обновлены, гарантированно сохраняя статус стека и данных, передаваемых в дальнейшие функции. Окей, тогда решение №2 — раз мы вылетаем при попытке обращения к переменной или функции по имени, может, нужно сократить их количество?

В карте на тот момент было около 3.5 тысяч глобальных переменных, бесчисленное множество локальных, а также 6 тысяч функций. Возможно, мы приблизились к какому-то лимиту переменных? При этом я прекрасно понимал, что существуют карты и с бОльшим количеством кода, а еще существует б-гмерзский GUI, где этих мусорных функций и переменных вообще не пересчитать, и они работают корректно. Но говорить, что во всем виноват мемхак, было бесполезно — если хуки не виноваты, то что еще?

За исключением хуков, мемхак обеспечивает возможность переписать данные внутри игры на свои собственные. Какие здесь могут быть проблемы? Я не записываю ничего в область, ответственную за поиск имён, не трогаю обработчики функций и т.п. Всё, что обеспечивает мх, это подмена или обновление статичных данных, которые не способны повлиять на такие структуры. Однажды, поймав фатал с неверным именем, я сумел подсмотреть, что игра искала переменную FOOD_USED (пишу по памяти, так что имя неточное). Которая не существует в моем коде вообще, как и в других файлах с JASS. Какого хрена?

Зато FOOD_USED встречается в коде самого варкрафта как алиас для переведенной строки, обозначающей «потребление пищи». Получается, варкрафт сам перезаписал имя переменной из кода на переменную из своего собственного? И какого же хрена, спрашивается, он продолжает обращаться сюда, если знает (должен знать), что здесь записана совсем другая иформация?



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

Узнать, как игра решает перезаписать строку, невозможно. Это ж какое количество хуков потребуется, чтобы просто охватить все места, где игра размещает строки изнутри? Не говоря уже о том, что анализатор тоже должен быть написан внутри сторонней библиотеки, да и лагать такая конструкция будет знатно. Более того, очевидно, что варкрафт считает этот участок памяти свободным — иначе бы он и не пытался размещать здесь свою «служебную» строку. А найти того, кто извещает игру о таком, и вовсе не моего уровня задача. Но одно я знал точно — в моем коде таких костылей не было.

Еще одну порцию боли я собрал от других картоделов, использующих мемхак. У них такой проблемы не было вообще — они свободно меняли нужные им данные и не страдали от сотен репортов. Самое время хвататься за голову, ага. Т.е. у них была та же самая инициализация, те же самые основные функции, но не было таких багов.



Окей, на таком затишье самое время попробовать подход от противного. С очередным обновлением LoD я добавил всю основу мемхака, необходимую для его работы, и несколько фишечек. Переход был успешным и карта быстро заработала на полную мощь. И… В ней не было фаталов. Ни одного репорта, ни одного краша, подобного тем, что были внутри самой доты. Закончил порты хуков — краши не появились. Расширил действие мемхака — всё равно не появились. А значит, проблема однозначно не в технологии, а в её использовании. Кроме того, в LoD оказалось намного больше функций и переменных, поэтому вариант с перегрузкой «содержания» тоже отпал сам собой.

Описанное происходило на протяжении, наверное, 4-х месяцев. И тут я заметил, что ооочень давно не ловил фатал самостоятельно. И это при том, что на дню перезапускаю карту раз по 50 минимум. Казалось бы, я должен вообще каждые игр 10 принудительно выходить из варкрафта, но нет — никакого дискомфорта не было. При этом фаталы никуда не исчезли — игроки продолжали жаловаться. А в чем разница между тестами и реальной игрой? В объеме потребляемого контента.

В частности, в играх непременно приобретают артефакты, а мне этого делать не требовалось. В играх больше взаимодействий со скиллами, чем в тестах. Но попытки перегрузить игру данными, пикнув всех героев и использовав все их способности, ничего не принесли — на моей машине карта была стабильнее, чем инструмент Джонни Синса.


Ща продиагностирует

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

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

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

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



Как я понял, происходит конфликт чистильщиков — при начале карты варкрафт проверяет, не осталось ли в памяти данных от прежней карты. Если такие найдены, запускается очистка, которая стирает ненужную информацию. Этот процесс срабатывает только при загрузке следующей карты, а не при выходе с предыдущей. Чистка есть и у виртуальной машины JASS, исполняющей код. Я не знаю, когда запускается она и входит ли она подпунктом к основной чистке или независима. В любом случае, скорее всего, имеет место конфликт чистки — либо игра уничтожает строку со ссылкой на модель первой, либо JASS движок уничтожает строку, не зная о том, что на неё есть ссылка в существующих структурах (он не может знать об этом, ибо не проектировался для подобных возможностей). Двойной чистки не происходит — скорее всего, JASS размечает освобожденное после чистки место под нативки или переменные, а затем штатный «дворник» повторяет чистку для конкретных адресов из старых таблиц с данными о юнитах, например. И получается, что список нативок поврежден случайной записью с адресом строки из служебного сектора. Ну, попытка дважды очистить одну и ту же память могла закончиться и хуже.

Заключение и TLDR

Если кто-то хочет играть со строками из JASS, то следует восстанавливать их статус на стандартное значение в конце игры. Для этого можно использовать хук на выход (присутствует только в последней, не выложенной еще версии мемхака), библиотеку для восстановления данных в памяти после выхода с игры (приложена на GitHub к мемхаку), или же восстанавливать вручную, если это разовое изменение, как например — в случае с дамми для телепорта.

Для смены модели у меня сейчас используется «стандартная» функция из самого варкарфта, которая корректно регистрирует строку в движке и не подвержена двойной очистке, т.к. строка копируется в хранилище «данных» вместо того, чтобы оставаться ссылкой на хранилище строк внутри JASS-движка. Второй вариант получается, если использовать GetStringAddress, т.к. вернется именно адрес внутри таблицы. А сама функция используется внутри кода с метаморфозами, откуда я её и вытащил.

7 комментариев

avatar
Кул стори
avatar
А что ты раньше передавал аргументом GetStringAddress? То есть, как ты получал нужный указатель, на который заменял? И где фактически хранилась строка, как она попадала в память? Вообще, ты заменял указатель на строку или на какую-то структуру, в которой был указатель на строку?

Не знаю, как в WC3 устроена работа со строками, но можно предположить, что каждый раз, когда ты где-то используешь строку, нужно увеличить счётчик использований. И потом, когда структурку освобождают, этот счётчик уменьшают. И из-за того, что ты не увеличивал счётчик, место под твоей строкой освободилось, а указатели остались. Или вообще освободилось несколько раз.

То есть восстанавливать ничего не надо. Надо следовать каким-то внутренним правилам использования строк, что ты сейчас и делаешь, как я понял. По-хорошему, нужно ещё для старой строки (которую заменяешь) уменьшать счётчик, чтобы она не оставалась в памяти.
  • tmn
  • 0
avatar
Спецификация по стрингам, нарытая сугубо эмпирическим путем — www.hiveworkshop.com/threads/documentation-string-type.240473/
В JASS стринги заменяются на объекты со ссылками на эти стринги. Адрес забирался банальным взятием оффсета из объекта. Строки не утилизируются до конца игры, поэтому счетчик использований значения не имеет (если он вообще есть) — все они чистятся при загрузке следующей карты, и JASS, и родные.

Да, фактически, теперь я использую функцию, которая как-то регистрирует строку в более глобальном хранилище. Ну а двойная чистка приводила бы к мгновенному фаталу, а не проблемам с целостностью. Единственный вариант, когда это возможно — это если JASS чистил за собой позже, а т.к. это виртуальная машина, она защищена от подобных вылетов кучей проверок по дороге.
avatar
Ну а двойная чистка приводила бы к мгновенному фаталу, а не проблемам с целостностью.
Нет, с чего бы. Да и одного free достаточно, чтобы всё испортить, если ты там вставлял указатель на часть JASS'овской таблицы строк. Тут и счётчик не нужен, если оригинальная строка (которую ты заменял) выделялась специально для этой структуры и потом освобождалась, когда освобождалась структура (особенно, если это происходило уже после выхода из карты).
avatar
Будут новые тесты на 1.27 или он юзлес и ничем не лучше 26?
avatar
смотрит героев
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.