Рецепты отладки. 40 минут до сбоя.

Эта история тоже довольно старая, произошла она во времена первой крейтовской разработки, и произошла на совершенно ровном месте. Мы делали отличную игру за инопланетянина, который мог вселяться в одного из четырех персонажей (паук, прыгун, летун, и большая обезьяна с присосками), камера от третьих глаз, большие природные пространства, 100% алгоритмичность прохождения, ну и, разумеется, полное отсутствие геймплея как такового. К слову сказать, ситуация была настолько типовой для нашего “савеццкаго геймдева”, что уже сильно позднее, работая в 1С, я “для себя” классифицировал множество наших разработчиков как например “Крейт образца 1998 года” или “Крейт образца 2001 года”. Впрочем, суть не в этом, а в баге, который был внедрен в систему за несколько месяцев до проявления.

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

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

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

В игре была реализована замечательная функция следующего вида: bool GetCollisionInfo([in] posFrom, [in] posTo, [out] collisionStruct). Она проверяла, если ли на отрезке от posFrom до posTo пересечения со статической сценой. Если от posFrom до posTo пересечения не было, функция возвращала false и ничего более не делала. Если обнаруживалось пересечение, функция возвращала true и заполняла информацию в collisionStruct о том, какой именно объект сколлизился, какая фаска, какая нормаль и какие координаты точки пересечения.

Трагизм ситуации заключался как раз в нестабильности по входным данным (цинично пользуюсь уже объявленной классификацией) плюс последующая внутренняя нестабильность метода. В том случае, если длина входного отрезка [posFrom; posTo] была меньше 10e-6, функция считала, что продвинуться в целевую точку невозможно (т.е. return true), но при этом не заполняла параметры в collisionStruct. Как оказалось, во время записи злосчастного профайла произошла ситуация, когда паук не долетел до стены буквально доли нанометра и на следующем фрейме одна из функций коллижена вернула результат из непроинициализированой переменной. После запуска демо на воспроизведение это значение, очевидно, стало другим.

Ошибка, безусловно, была исправлена, но вот разные мысли остались. Мысли эти были следующими:

  1. Налицо была ошибка первого рода – обработка ситуации с пограничными данными (posTo сильно близко к posFrom) была проведена недостаточно качественно.
  2. Программист, реализовавший функцию, недостаточно чётко представлял себе “отклик” функции. В нашем случае откликом должно было быть false и незаполненная структура или true и заполненная структура. В пограничной ситуации отклик был неожиданным – true и незаполненная структура.
  3. Вместе с нестабильностью первого рода(пограничные параметры) налицо нестабильность третьего рода (функция вернула результат, который не является правильным откликом). Уверен, если бы в момент первоначальной реализации функции в конце её была бы проассертирована эта нестабильность, ошибка была бы поймана задолго до реализации демоплея. В нашем случае для диагностики нестабильности третьего рода было бы достаточно проверок:
  • либо функция вернула false;
  • либо функция вернула true и при этом:
  • -    координаты точки пересечения принадлежат отрезку posFrom…posTo;
  • -    индексы объектов и фасок являются корректными;
  • -    AABB объекта содержит в себе точку коллизии;

Варианты можно расширять или сужать в зависимости от особенностей алгоритма. Но общее понимание правильного отклика функции на любые текущие аргументы или на любое текущее состояния объекта/модуля должно всегда присутствовать в голове у программиста.

Приятной вам отладки.

  • CEMEH

    Смешно, забавно, познавательно. Действительно, непонятно как ловить, кроме как жесткими ассертами в функции :)

    Я когда был маленький (да и сейчас иногда), мне про эти corner cases бывает просто лень думать. Это ж надо подумать про точность float, представить гипотетически, без отладчика, а можем ли мы свалиться в такой не очень ожидаемый стейт. Когда надо быстро зафиксить (bail out if length < 10e-6) и код сложный, легко проебать.

    Кроме вечного совета “не лениться думать”, который на самом деле про опыт, можно только посоветовать чистить память и ассертами заранее проверять корректность контракта.
    У нас в guidlines кстати кажется сразу записано, что если нет performance reasons, out parameter надо вначале зачистить нулями, а потом уже логику писать.

  • kasym

    А ведь тулсы получше могли бы это автоматически решать. Достаточно возвращать «Maybe collisionStruct» или «Nullable» и компилятор бы не дал вернуть незаполненную структуру…

  • kasym

    (должно было быть Nullable(collisionStruct), из C# 2.0, но парсер html съел кусочек моего коммента)

  • shvez

    Дима, хватит нам желать приятной отладки. Пожелай нам лучше пребывания в стабильности кода :)

  • kas

    а есть кстате “крейт таким не будет никогда (((” ну или “крейт таким будет нискоро (((“?