>
>
>
Игоры! Как пишут код для SDL (+ интервь…

Антон Третьяков
Статей: 5

Игоры! Как пишут код для SDL (+ интервью с создателем)

Джек Лондон как-то написал: "Не стоит ждать вдохновения, за ним надо гоняться с дубинкой". Если речь идёт о создании игр, то проект SDL вполне себе может послужить такой дубинкой. Но как он сделан сам?

Если вы когда-либо хотели сделать свою игру, вам наверняка встречалась SDL. Если вы хотели научиться рисовать штуки на экране без регистрации и смс, вам наверняка встречалась SDL. Если вам надо было инициализировать OpenGL, то вам точно встречалась SDL.

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

Среди игр, так или иначе использующих SDL, встречались такие тайтлы, как Neverwinter Nights, Dwarf Fortress, Amnesia, VVVVVV (о которой есть схожая статья) и великая и ужасная Teeworlds (в которой Всегда Ваш провёл времени хукуя туда-сюда больше, чем ему хотелось бы признавать).

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

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

Ну что, хватить терять время, вперёд!

По ходу статьи мы используем примеры кода. Пометки в них, обозначенные как многоточие "....", были добавлены автором статьи.

Сами исходники можно найти на официальном GitHub libsdl. Помимо этого, каждый фрагмент снабжён ссылкой на конкретное место в коде.

К моменту публикации этой статьи многие места уже поправлены после открытых нами issues (например, здесь и там), но ссылки в примерах указывают именно на код, который вы встречаете в этой статье. Нам очень приятно не только тормошить проекты, но и делать их немного лучше!

Доверяй, но проверяй

Насколько, читатель, вы доверяете документации стандартной библиотеки языка С? Хотя более подходящим нашему открывающему примеру будет скорее вопрос о том, насколько вы доверяете самим себе во время её использования? Как вы думаете, что не так с примером ниже?

Файл: SDL/src/stdlib/SDL_iconv.c (GitHub permalink)

char *SDL_iconv_string(const char *tocode, const char *fromcode,
                       const char *inbuf, size_t inbytesleft)
{
  SDL_iconv_t cd;
  ....

  cd = SDL_iconv_open(tocode, fromcode);
  if (cd == (SDL_iconv_t)-1) {
    /* See if we can recover here (fixes iconv on Solaris 11) */
    if (tocode == NULL || !*tocode) {
      tocode = "UTF-8";
    }
    if (fromcode == NULL || !*fromcode) {
      fromcode = "UTF-8";
    }
    cd = SDL_iconv_open(tocode, fromcode);
  }
  ....
}

"Я, может быть, и сказал бы, — возразит внимательный и чуткий читатель, — сказал, если бы мы имели дело со стандартной функцией iconv_open. Но тут же какая-то местная имплементация SDL_iconv_open!". Отличное замечание! Вот как имплементирована эта функция:

Файл: SDL/src/stdlib/SDL_iconv.c (GitHub permalink)

SDL_iconv_t SDL_iconv_open(const char *tocode, const char *fromcode)
{
  return (SDL_iconv_t)((uintptr_t)iconv_open(tocode, fromcode));
}

Разобравшись с этим, давайте теперь посмотрим на её окрестности. Код чуть ниже даёт понять, что на вход вполне могут быть переданы нулевые указатели в аргументах tocode и fromcode. В случае этого они перед проверкой с ветерком полетят и в саму iconv_open.

Если мы посмотрим в man страницу данной функции проекта GNU, то не увидим никакого упоминания о том, что NULL толкать в неё нельзя. "Дело раскрыто!" — скажет молодой сотрудник нашего сыскного агентства, но детектива не проведёшь! Он слишком любит смотреть исходники Сишной библиотеки, ведь смотреть исходники Сишной библиотеки — весело! Узрите и вы!

Ну... обращения по указателю, конечно, не происходит, но вполне себе присутствует вызов strlen без проверки на NULL. Все мы знаем, что strlen делает в этом случае, и мало кому это по душе!

Но если вы, читатель, решили, что на одной имплементации мы остановимся, то поспешим вас убедить в обратном — BSD и Musl ведут себя равным образом.

Казалось бы, безобидное место, даже проверка, казалось бы, есть! Но своего дилера библиотеки всегда нужно знать в лицо! Кстати, а как часто вам, читатель, приходится знакомиться с исходниками Вашего вендора? Давайте пообщаемся в комментариях!

Ну а анализатор выдал ёмкое:

V595 The 'tocode' pointer was utilized before it was verified against nullptr. Check lines: 37, 789, 792. SDL/src/stdlib/SDL_iconv.c:792:1

Карго культ

Скажите, читатель, есть ли у вас такие маленькие ритуалы, которые обязательно надо соблюсти — иначе ничего не заработает? Погладить резиновую уточку, сохранить файл с исходниками три раза подряд? Ниже представлен пример схожего поведения:

Файл: SDL/src/events/SDL_mouse.c (GitHub permalink)

SDL_Cursor *SDL_GetCursor(void)
{
  SDL_Mouse *mouse = SDL_GetMouse();
  if (mouse == NULL) {
    return NULL;
  }
  return mouse->cur_cursor;
}

"Что сейчас-то не так?" — вскрикнет читатель. Указатель запрашивается, указатель проверяется — всё честно! Понимаем, мы же, в конце концов, сами писали выше, что так и надо делать! Но призываю вас придержать возмущение и посмотреть, откуда он берётся.

Файл: SDL/src/events/SDL_mouse.c (GitHub permalink)

SDL_Mouse *SDL_GetMouse(void)
{
  return &SDL_mouse;
}

Так-так, что это за переменная такая? Давайте узнаем!

Файл: SDL/src/events/SDL_mouse.c (GitHub permalink)

static SDL_Mouse SDL_mouse;

Глобальная переменная! То есть NULL здесь неоткуда взяться, глобальная переменная всегда лежит в памяти с конкретным адресом.

Что сказать анализатору, кроме ёмкого:

V547 Expression 'mouse == NULL' is always false. SDL/src/events/SDL_mouse.c:1376:1

P.S. Принимая во внимание сказанное выше, тело рассматриваемой функции можно переписать следующим образом:

return SDL_GetMouse()->cur_cursor;

Некоторые внимательные читатели могут обоснованно заметить один факт. При возможной замене использования статической переменной на аллокацию отсутствие проверки на NULL сыграет злую шутку. Мы спешим успокоить – в данном случае любой уважающий себя статический анализатор подскажет, что на NULL нужно проверить. Таким образом, сохранятся и слой абстракции, и нервы программиста.

Просим и тех читателей, которые справедливо заметят, что "каждый указатель, возвращаемый из функции, должно проверять несмотря ни на что" выделить нам в долг толику терпения и подождать пару примеров – обещаем, ниже мы обязательно вернёмся к этой теме с ещё одним занимательным примером!

Либо делай, либо не делай

А вы знаете, что количество пальцев у магистра Йоды меняется от фильма к фильму? А теперь — обратно к языку С! Есть некоторая структура:

Файл: /SDL/src/render/SDL_yuv_sw_c.h (GitHub permalink)

struct SDL_SW_YUVTexture
{
  ....
  int w, h;
  ....
};
  
typedef struct SDL_SW_YUVTexture SDL_SW_YUVTexture;

Которая используется в некотором месте:

Файл: /SDL/src/render/SDL_yuv_sw.c (GitHub permalink)

int SDL_SW_UpdateYUVTexture(SDL_SW_YUVTexture *swdata, const SDL_Rect *rect,
                            const void *pixels, int pitch
{
  ....
  SDL_memcpy(swdata->pixels, pixels,
       (size_t)(swdata->h * swdata->w) + 
            2 * ((swdata->h + 1) / 2) * ((swdata->w + 1) / 2));
  ....
}

Когда говорят, что нужно периодически обращаться к классике, чаще всего речь идёт не о классических ошибках при конвертации типов в языке С. Посмотрите на выражение swdata->h * swdata->w, потом на типы его операндов и в конце на тип, к которому оно приводится. Согласитесь, было бы корректнее сначала приводить операнды, после чего уже заниматься их перемножением.

Автору статьи, конечно, стоит заметить, что он не представляет себе, какого размера должна быть текстура, чтобы её стороны при перемножении привели бы к переполнению. Но в завершение этого раздела Всегда Ваш взял бы на себя смелость реанимировать и такую вот статью от бывшего сотрудника Google и автора некоторых частей Java. В ней он описывает, как простой mergesort в течение многих лет содержал потенциальный баг с переполнением, который долгое время физически не мог выстрелить, но всё-таки дождался своего.

Анализатор же предупреждает:

V1028 Possible overflow. Consider casting operands of the 'swdata->h * swdata->w' operator to the 'size_t' type, not the result. SDL/src/render/SDL_yuv_sw.c:125:1

P.S. Код под вопросом, например, можно было бы заменить таким образом:

(size_t)(swdata->h) * (size_t)(swdata->w) + 
 (2 * (((size_t)(swdata->h) + 1) / 2)) *
  (((size_t)(swdata->w) + 1) / 2);

Впрочем, зачем платить за безопасность читаемостью кода? Давайте перепишем его следующим образом:

const size_t h = swdata->h;
const size_t w = swdata->w;
SDL_memcpy(swdata->pixels, pixels,
           h * w + 2 * ((h + 1) / 2) * ((w + 1) / 2));

Тень сомнения

Как говорил Альфред Хичкок: "Получение указателя из функции сродни женщине — чем больше додумываешь, тем сильнее эмоции". Какие эмоции вызывает у вас, читатель, следующий фрагмент?

Файл: SDL/src/joystick/linux/SDL_sysjoystick.c (GitHub permalink)

static SDL_JoystickGUID LINUX_JoystickGetDeviceGUID(int device_index)
{
  return GetJoystickByDevIndex(device_index)->guid;
}

Согласитесь, саспенс начинает зарождаться уже от самого факта обращения по непроверенному указателю. Но, может, GetJoystickByDevIndex не может вернуть ничего такого? Давайте посмотрим:

Файл: SDL/src/joystick/linux/SDL_sysjoystick.c (GitHub permalink)

static SDL_joylist_item *GetJoystickByDevIndex(int device_index)
{
  ....
  if ((device_index < 0) || (device_index >= numjoysticks)) {
    return NULL;   }
  ....
}

NULL, вот мы и встретились снова!

"Во взрыве нет ничего страшного. То ли дело — ожидание взрыва!" — сказал как-то мэтр. Но если взрыв ведёт к запуску отладчика — это уже совсем другая история!

Анализатор соглашается:

V522 There might be dereferencing of a potential null pointer 'GetJoystickByDevIndex(device_index)'. SDL/src/joystick/linux/SDL_sysjoystick.c:1013:1

P.S. Внимательный читатель заметит, что в секции "Карго культ" выше мы сами прямо советовали использовать код, который в примере этом требуем исправлять. Стоит согласиться, что считать практически идентичный код правильным и неправильным может быть несколько дезориентирующим.

В том примере выше мы говорили о том, что писать точно такой же код может быть безопасно, так как анализатор подскажет, если функция после переделки кода вдруг сможет возвращать NULL. Фактически мы подразумевали, что безопасность эта гарантируется статическим анализатором, а это движение прямо противоположное философии безопасного программирования (defensive programming). Поэтому мы хотели бы пригласить читателей в комментарии для своего рода философской беседы: как вы думаете, а насколько полезные инструменты разработчика могут и должны влиять на стиль кода?

Среди трёх берёз

А в следующем фрагменте предлагаем посмотреть, как можно на ровном месте выстрелить себе в ногу:

Файл: /SDL/src/libm/e_exp.c (GitHub permalink)

double __ieee754_exp(double x)
{
  int32_t k=0;
  .... 
  if(k >= -1021) {
    u_int32_t hy;
    GET_HIGH_WORD(hy,y);
    SET_HIGH_WORD(y,hy+(k<<20));    /* add k to y's exponent */
    return y;
  }
  ....
}

Переменная k может принимать отрицательные значения, с которыми она потом, весело крича и улюлюкая, сдвинется на двадцать битов влево.

Во времена, когда по земле ходили динозавры и компилировали код Борландом, в ходу (говорят!) были разные платформы, которые могли и не использовать дополнения до двух для представления отрицательных чисел. Когда язык С был впервые кодифицирован, он учитывал это разнообразие. А если нужно учитывать интересы такого количества сторон, значит, и строгие гарантии по ряду вопросов дать станет несколько затруднительно. Подобная операция побитового сдвига не может давать вменяемого платформеннонезависимого результата в случае, если левый операнд — негативное число. Оттого и UB!

Между тем, есть ли в рядах наших уважаемых читателей такие, кому и сейчас приходится работать с "неортодоксальными" платформами? Прошу, напишите в комментарии — это очень интересно!

Анализатор говорит:

V610 Undefined behavior. Check the shift operator '<<'. The left operand is negative ('k' = [-1021..2147483647]). SDL/src/libm/e_exp.c:159:1

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

Файл: /SDL/src/libm/e_rem_pio2.c (GitHub permalink)

int32_t attribute_hidden __ieee754_rem_pio2(double x, double *y)
{
  int32_t e0,i,j,nx,n,ix,hx;
  GET_HIGH_WORD(hx,x);         /* high word of x */
  ix = hx&0x7fffffff;
  ....
  if(ix<=0x413921fb) {
    ....
    if(hx<0) {y[0] = -y[0]; y[1] = -y[1]; return -n;}
    else     return n;
  }
  ....
  if(ix>=0x7ff00000) {        /* x is inf or NaN */
    y[0]=y[1]=x-x; return 0;
  }
  ....
  e0  = (ix>>20)-1046;        /* e0 = ilogb(z)-23; */

  SET_HIGH_WORD(z, ix - ((int32_t)(e0<<20)));
}

"Что-то там двигается тудова-сюдова", — воскликнул Всегда Ваш, когда впервые увидел данный код. А двигается оно в сторону UB, не поверите! Посмотрите на выдачу анализатора ниже — переменная e0 вполне себе может принять отрицательное значение! Посудите сами, минимальное возможное значение для переменной ix, при котором не произойдёт раннего выхода из функции — 0x413921fb + 1. Двигаем его вправо на 20 битов, получаем 1043. Отнимите 1046 и распишитесь в получении искомого UB при сдвиге влево. Как говорила моя мама в детстве: "Не играйся с хексами, всё взорвёшь!"

Спасибо, анализатор:

V610 Undefined behavior. Check the shift operator '<<'. The left operand is negative ('e0' = [-3..1000]). SDL/src/libm/e_rem_pio2.c:151:1

P.S. Мы обязаны добавить и такое уточнение: если вы используете машину с архитектурой, в которой используется дополнение до двух для представления отрицательных чисел, это вовсе не означает, что код будет работать ожидаемым вами образом. Сдвиг отрицательных чисел влево — всегда UB. Все остальные представления о UB и о том, как на самом деле будет работать код, являются как минимум рискованными, если не сказать, что вовсе неверными.

Самый быстрый ковбой на Диком Западе

Как говаривала моя бабуля: "Быстрым надо быть при ловле багов!". "А при написании кода их лучше избегать", — добавит Всегда Ваш следом. Давайте обратимся к коду ниже:

Файл: /SDL/src/joystick/linux/SDL_sysjoystick.c (GitHub permalink)

static SDL_bool LINUX_JoystickGetGamepadMapping(
    int device_index, SDL_GamepadMapping *out)
{
  ....
  joystick = (SDL_Joystick *)SDL_calloc(sizeof(*joystick), 1);
  joystick->magic = &SDL_joystick_magic;

  if (joystick == NULL) {
    SDL_OutOfMemory();
    return SDL_FALSE;
  }
  ....
}

В данном случае нам не то чтобы важно, что конкретно находится в структуре joystick. Важно то, что к ней сначала обращаются, а только после этого спрашивают, а можно ли это вообще-то делать. Действительно, можно ли найти лучшее место в этой статье для самой царственной из поговорок — Festina lente!

А анализатор же цитирует так:

V595 The 'joystick' pointer was utilized before it was verified against nullptr. Check lines: 2119, 2120. SDL/src/joystick/linux/SDL_sysjoystick.c:2120:1

Так и было задумано!

Подходя к завершению рассмотрения нашей сегодняшней коллекции, хотелось бы напомнить, что ошибки в программе могут возникать и из простого желания отложить правку на потом:

Файл: SDL/src/render/software/SDL_triangle.c (GitHub permalink)

int SDL_SW_FillTriangle(SDL_Surface *dst, 
                        SDL_Point *d0, SDL_Point *d1, SDL_Point *d2,
                        SDL_BlendMode blend, 
                        SDL_Color c0, SDL_Color c1, SDL_Color c2)
{
  ....
  if (dst->format->Amask) {
    color = SDL_MapRGBA(tmp->format, c0.r, c0.g, c0.b, c0.a);
  } else {
    // color = SDL_MapRGB(tmp->format, c0.r, c0.g, c0.b);
    color = SDL_MapRGBA(tmp->format, c0.r, c0.g, c0.b, c0.a);
  }
  ....
}

"Может, так и было задумано?" — спросит пытливый читатель. Но если так и было задумано, то зачем здесь условие?

Анализатор задаётся таким же вопросом:

V523 The 'then' statement is equivalent to the 'else' statement. SDL/src/render/software/SDL_triangle.c:341:1

Интервью

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

Кажется, что для работы над проектом уровня SDL требуется достаточно высокий уровень квалификации. Расскажите, сколько разработчиков в нём задействовано?

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

Некоторые функции в SDL работают с достаточно низкоуровневыми вещами, например, с экспонентой в ieee754_exp. Встречаются ли какие-то особые трудности при написании и тестировании такого кода?

Для таких целей мы стараемся использовать код, который уже находится в открытом доступе и хорошо протестирован. Однако лицензирование такого кода не должно противоречить лицензии SDL. В этом случае мы использовали математические функции из uClibc. А при написании собственного кода нам очень помогают многолетний опыт и увлечённые разработчики, которые готовы комментировать и тестировать внесённые нами изменения.

SDL есть практически везде. В моем дистрибутиве Arch он даже был предустановлен (чему я был особенно рад). Можете рассказать о процессе доставки кода из редактора разработчика на такое разнообразие платформ и репозиториев?

Раньше мы просто размещали подписанный исходный код на нашем сайте, а специалисты, отвечающие за обслуживание дистрибутивов, включали релизы SDL в обновления ОС. В настоящее время наши релизы доступны на GitHub, а для мейнтейнеров процесс работы в основном остался прежним. В рамках релиза мы выпускаем бинарники для Windows и Apple — это облегчает задачу тем, кто хочет внедрить новый SDL в уже существующий продукт и иметь доступ ко всем исправлениям и улучшениям.

SDL поддерживает множество платформ. Поддержку для какой из них было труднее всего реализовать?

Android и iOS оказались наиболее сложными, поскольку модель приложений и API сильно отличаются от десктопных платформ.

Как следите за тем, чтобы вы или другие разработчики не сломали ничего в SDL в процессе написания нового кода или редактирования старого?

У нас есть автоматизированные тесты, которые охватывают достаточно большой объем функционала SDL. В целом, мы знаем из собственного опыта, к чему может привести обращение к той или иной части кода. Если какие-то изменения несут риски, мы вносим их в самом начале очередного этапа работы, чтобы как следует протестировать перед релизом.

Тестируете ли вы SDL для каждой платформы отдельно?

Да.

Не поделитесь стеком, который вы используете для тестирования?

Мы используем различные тестовые программы, включённые в репозиторий SDL. Мы запускаем их с помощью GitHub Actions в ответ на коммиты, чтобы убедиться, что они не вызывают проблем. Одна из самых приятных особенностей экосистемы GitHub заключается в том, что тесты SDL выполняются на основе запросов от других людей. Так они могут быть уверены, что их код работает хорошо, прежде чем мы зальём его в основной репозиторий.

Заключение

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

Надеюсь, читатель, и вам было так же интересно читать эту статью, как и её автору исследовать проект! Как обычно, Всегда Ваш надеется прочитать в комментариях ваши мысли, пожелания и рекомендации.

И, конечно же, спасибо, Глава рыцарей Лантинга!

Благодарим, что дошли до конца! El Psy Kongroo.