История одной карты на одном патче

TL;DR: История крупного бага, который был пофикшен буквально чудом. Причина бага — ужасная документация изменений в новых патчах WC3 и переоптимизация самой карты. Текст слегка технический и больше нудный, чем детективный. Я предупредил.

В конце прошлого года было принято решение, что RGC должны перейти на версию 1.27. Причина банальна — пользователей Win10 стало слишком много, это же развивающаяся Европа, а не Средняя Азия. А Win10 в принципе не очень дружелюбна к старому софту, особенно — если речь про игру, которая создавалась еще во времена Windows 98. Поэтому миграция была необходима. К счастью, 27а вполне себе совместима с мемхаком, поэтому никаких проблем не предвидилось.

Запускать карту на RGC было невозможно из-за старого клиента, т.к. не было поддержки встроенной DLL — античит воспринял бы её как чит. Поэтому тесты велись на 1.26, на гарене и ей подобных клиентах. Дело шло медленно, т.к. прямого доступа к хост-боту не было, а хостить самому на слабом интернете — то еще удовольствие. Но худо-бедно, патчи клепались, прогресс набирался, карта становилась стабильной. И, как это обычно бывает, в тихой гавани началась буря.



Выход на RGC
В апреле RGC, наконец, обновили клиент, и появилась возможность загружать карты на боты напрямую. Была выделена отдельная комната для тестеров, куда мы незамедлительно и двинули. По моей рекомендации, RGC использовал система авто-патчинга — перед стартом игры ключевые файлы заменялись на аналоги из 27-й версии, после игры — на оригиналы. Это позволяет не менять версию варкрафта вручную, а просто нажать «Play» и забыть о деталях.

После первой же пары тестовых игр всплыли очередные баги. На этот раз, пытаясь запаковать 6-слотовую Дриаду через Aghanim, Hurricane Pike и Bloodthorn, не удалось двакликом собрать последний артефакт. Рецепт просто отказывался нажиматься — отображалось лишь стандартное "Can't target item". Ошибка была явно стандартной, по всем параметрам, поэтому в коде проблем быть не могло. При этом на моем герое клики по рецептам работали нормально. Проверил саму способность, которая отвечает за сборку рецептов — ничего подозрительного. Но она всё равно не хочет применяться, как должна.



Полное отсутствие идей заставило забить на этот баг, тем паче, что случался он не часто. В нескольких 5х5 играх о нем вообще не вспомнили, так что приоритет был маленький. Зато напомнили о себе мои любимые фаталы, которые случались как на этапе загрузки, так и в ходе игры. Радовало лишь то, что отлетал всего 1 игрок, а не все разом. Логика говорила, что всё зависит от текущего состояния игры на конкретном компьютере — потому что со второго rmk игрок обычно не вылетал, хотя ничего и не менял на своей стороне.

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

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

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

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



Раскопки продолжаются
Первым делом был встроен хук, который записывал все обращения. Фатал происходил случайно, по неизвестной причине, в случайное время, и повторить его специально было невозможно. Поэтому и потребовалось просто собирать всю информацию, что доступна. К счастью, долго ждать не пришлось — при просмотре одного из реплеев внезапно случился краш, и хук успешно записал название запрашиваемой функции. Это было «A0A5». WTF?

Это стандартный 4-значный ID, в данном случае — от способности «Summon Spirit Bear». Это не имя функции, и как строка в игре оно вообще не встречается. Как это могло придти на обработку к нашей драгоценной функции?

По нативкам, которые выполнялись незадолго до падения, выяснил, что фатал случился вооот здесь:


Последняя выполненная нативка — RegisterDeathEvent. После неё — запрос адреса функции «A0A5». По правилам, аргументы любой функции передаются задом наперед (первый — последним), поэтому вылет произошел на "function Func3927". Игра попыталась найти адрес этой функции, но почему-то запросила совсем другое имя.

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



Строки в JASS
Для оптимизации доступа все строки и названия, существующие в JASS, преобразуются в специальные объекты-контейнеры. Они хранят хеш строки, ссылки на предыдущий и следующий контейнер, и ссылку на саму строку. При любых сравнениях строк используется именно хеш, а не побайтовое сравнение двух строк. Объекты строк появляются при ЛЮБОМ объявлении или генерации строки, т.е. там творится лютый ад, когда идет перебор длинной строки посимвольно (каждая буква попадает в этот хеш, хотя мы к ней возвращаться и не планировали больше). Сами строки переносятся в специальное хранилище и располагаются в порядке добавления.

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

Чтобы работать со строками из мемхака, сперва необходимо их правильно прочитать. И для этого приходилось использовать немного грязный трюк конвертации, но детали излишни. Первая попытка — удаление всех операций по работе со строками, какие возможно удалить без ущерба функционалу карты. Так появились b2 и b3. Однако — безуспешно, фатал был пойман на первой же тест-игре снова. Хук на функцию создает слишком большую нагрузку, поэтому в реальной игре, без дебага, узнать проблемное имя невозможно. Но нам и не нужно — мы и так знаем первопричину.

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

Ну, пойдем другой баг разгребем
На безрыбье внезапно появился другой баг, причем воспроизводимый. В игре ВНЕЗАПНО начинаются дикие фризы и лаги, до степени «слайды раз в 10 секунд». Сгреб софт, пошел смотреть. Оказалось, что проблему создает Леорик, когда идет фармить нейтралов. Согласно логам, он наносит нейтралу 0 урона, что вызывает разные триггеры урона. И так — примерно 60 раз в секунду. Скорее всего, просто где-то цикл получился. Окей, идем смотреть.

После пары перезапусков стало понятно, что триггеры не виноваты — циклов тупо не было, ни один триггер не повторяет урон. Просто в игре внезапно возникло 60 событий урона по 0 каждый. Что за херня тут творится? Промотал еще на разок, присмотрелся — проблема началась в момент, когда Леорик попытался кинуть мидас в маленького нейтрала-кентавра. Мало того, что тот не умер, так еще и получил неизвестный дебаф, а на мидасе кд не начался.



Мидас основан на Acid Bomb и по умолчанию имеет дебаф-эффект. Просто из-за скорости срабатывания (убийства) увидеть этот эффект на цели невозможно. Здесь же баф остался, а т.к. рассчет был на мгновенную смерть, его параметры не были настроены должным образом — во всех полях стояли нули. А WC3 расценивает 0 в поле «периодичность нанесения урона» как «ебашь так быстро, как только успеешь», поэтому и происходило по 1 событию урона на внутриигровой такт. Это понятно, но кто сломал мидас? В логах удалось также рассмотреть, что при касте мидаса ID способности оказался неверным — такого быть вообще не может. При каждом перезапуске ID был новый — мемхак же обычно оперирует статичными адресами и значениями.



Начались масштабные игры с реплеем — я менял карту, добавляя дебаг и отключая функции, надеясь добраться до момента, когда баг больше не повторится и мидас сработает. Но всё было тщетно — как ни старался, каждый раз, когда леорик собирал мидас, вместо нормальной активной способности он получал какой-то треш. Реплей прокручивался десятки раз, и это без поддержки Replay Seeker, который навсегда остался для версии 1.26.

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

Развязка
К счастью, Караулов догадался пойти повторить его в одиночной игре, и выяснил, что проблема в Леорике. Достаточно переключить ауру в статус «только для героев» и подобрать мидас, чтобы баг случился вновь. И тут до меня дошло — при переключении герой получает спец абилку ANeg, которая превращает ауру вампиризма в херо-онли. Если аура находится в стандартном состоянии «для всех», то бага не появляется. Но стоит остаться с абилкой, как мидас сразу сбрасывается на нулевые настройки, к багу.

Последней каплей стал факт, что в 1.26 баг не повторяется — там мидас остается рабочим. Это объяснило, почему ошибка с предметами не всплыла раньше. Ведь и дриаду пытались запаковать в новые слоты ПОСЛЕ аганима, а каждый аганим — это апгрейд через ANeg. Способности на базе ANeg имеют все герои с превращениями из ближней формы в дальнюю и наоборот, поэтому на них предметы тоже ломались и сбрасывались до некорректных настроек.

Виной всему оказались скрытые изменения внутри парсера, который анализирует данные способностей. Долгое время навыки с ANeg выглядели так:
C;X2;K"ANeg"
C;X11;K1
C;X25;K"A2XQ,A2XR"
C;X26;K_,_
C;X27;K_,_
C;X28;K_,_


Предусмотрено 4 поля для улучшения 4-х способностей. Если столько улучшать не нужно, лишние поля я заменял на "_,_". В оригинале, у фрога, там были просто копии предыдущих строк, типа

C;X25;K"A2XQ,A2XR"
C;X26;K"A2XQ,A2XR"
C;X27;K"A2XQ,A2XR"
C;X28;K"A2XQ,A2XR"


Опытным путем было установено, что мой «короткий» формат распознается в 1.27а некорректно. Если в 26 выражение эквивалентно «улучши способность _ в способность _», то в 27 оно превратилось в нечто другое. Соответственно, в версии 26 такой способности у юнита не находится, и всё работает как следует. А уже в версии 27 игра пытается рандомно переварить любую способность, появляющуюся после добавления ANeg, во что-то иное. При этом вместо целевого ID в памяти лежит мусор от других вычислений — отсюда и случайные ID.

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

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

avatar
Весёлые истории экран покажет наш, весёлые истории…
  • Loki
  • +1
avatar
наградабаш
avatar
Я может сейчас обосрусь, как человек считающий себя шарящим в картострое, но...

Почему нужно менять ауру Лёрика через «Технику»?
Я понимаю что этим добивашься при замене, но… разве оно не лишнее?
Ты можешь дать герою пустышку, или даже саму «Кнопку» переключения
А ауру добавлять отдельно, надо общую — даёшь общую, надо vip — отбираешь общую и даёшь vip… а так ты ещё и технику сюда подключил — дал/отнял её = переключил.
Неужели экономия? «Кнопка» всё равно будет использоваться.

Почему надо было делать именно так?
avatar
Кнопка и так отдельно от ауры. Апгрейд позволяет вообще не заморачиваться с добавлением-отниманием абилок, а делать всё волшебство в 3 строки нативными способами. Нативные методы всегда лучше любых велосипедов, при условии, что побочных эффектов нет.
avatar
Интересный пост, спасибо.

По моей рекомендации, RGC использовал система авто-патчинга
Порекомендуй им теперь создавать файл «needs_revert» при замене файлов и удалять его после восстановления оригиналов. Чтобы после креша RGC-клиента они восстановились при повторном запуске и выходе.

Просто на том месте, где должна была быть строка Func3927, оказалось A0A5. Ну и какого хрена?
Так а как байткод для этого куска выглядел? JASS скрипт в байткод ведь компилится, или нет? Что там на стеке лежало при вызове Condition? «Func3927» это имя JASS функции? При чём тут вообще поиск строки по хэшу, если нужно найти адрес тела по имени (хэшу имени?) функции?

Или в байткоде хэш имени и по нему сначала ищется имя, а потом по имени — адрес функции? Как коллизии обрабатываются для этого хэша?

Если в 26 выражение эквивалентно «улучши способность _ в способность _», то в 27 оно превратилось в нечто другое.
Во что? Что конкретно там происходило? Если это не портило хэш-таблицу со строками, то как оно могло вызывать первый баг?

Забавно будет, если окажется, что это близы багнули интерпретатор в 1.27.
  • tmn
  • +1
avatar
В стеке был кусок из таблицы строк, только поэтому и вычислил проблему. Та внутренняя функция всегда принимает char-строку, а вот сам поиск, скорее всего, по хешу делает, снятому с этой строки. Коллизий у хеша мало, хоть и возможны, но на практике никто не встречался с подобным. Согласно данным Леандро, во избежание используется разбиение таблицы строк на несколько разделов — в одной стандартные нативки, в другой — user-defined данные.

Хз во что превращалось, но эффект аналогичен пустой строке или некорретным данным по тип «0,0». Едва ли «багнули», с их точки зрения — скорее починили.
avatar
не в стеке, а в дампе памяти, что шел вместе с ткст фатала
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.