Должен признать, у меня есть некая особая любовь к классическому DOOM. Несмотря на то, что игре уже 31 год, в нее все еще весело играть самому (хотя и выходит у меня так себе) или просто смотреть на то, как другие в нее играют (вот в этом я показываю себя лучше); и поскольку исходный код игры открыт, ей можно наслаждаться на любой современной платформе — ПК, смартфон, камера, осциллоскоп — да и вообще на любой вещи, которая придет вам в голову. В результате чего, благодаря ряду обстоятельств, я оказался меинтейнером нескольких связанных с DOOM пакетов в Fedora Linux.

Итак, за несколько месяцев до нового релиза, проект Fedora Linux осуществляет массовую сборку всех пакетов. Это имеет несколько преимуществ — позволяет убедиться в совместимости ABI, обновить статически линкуемые зависимости, использовать новые оптимизации компилятора и так далее. Как бы то ни было, с приближением релиза Fedora Linux 42 в середине апреля, пришло время для массовой пересборки, и как часто бывает, не все пакеты выжили. Одним из пакетов, которые не удалось собрать оказался chocolate-doom.

Две неправды правду не дают

Ладненько, что поделать. В первую очередь надо проверить логи сборки.

gcc -DHAVE_CONFIG_H -I. -I../..    -I../../src -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT -O2 -g -Wall -Wdeclaration-after-statement -Wredundant-decls -O2 -flto=auto -ffat-lto-objects -fexceptions -g -grecord-gcc-switches -pipe -Wall -Werror=format-security -Wp,-U_FORTIFY_SOURCE,-D_FORTIFY_SOURCE=3 -Wp,-D_GLIBCXX_ASSERTIONS -specs=/usr/lib/rpm/redhat/redhat-hardened-cc1 -fstack-protector-strong -specs=/usr/lib/rpm/redhat/redhat-annobin-cc1  -mbranch-protection=standard -fasynchronous-unwind-tables -fstack-clash-protection -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer  -I/usr/include/SDL2 -D_GNU_SOURCE=1 -D_REENTRANT  -I/usr/include/libpng16 -DWITH_GZFILEOP -I/usr/include/pipewire-0.3 -I/usr/include/spa-0.2 -D_REENTRANT -I/usr/lib64/pkgconfig/../../include/dbus-1.0 -I/usr/lib64/pkgconfig/../../lib64/dbus-1.0/include -I/usr/include/libinstpatch-2 -I/usr/include/glib-2.0 -I/usr/lib64/glib-2.0/include -I/usr/include/sysprof-6 -pthread -I/usr/include/opus -D_DEFAULT_SOURCE -D_XOPEN_SOURCE=600 -c -o deh_bexstr.o deh_bexstr.c
In file included from ../../src/sha1.h:21,
                 from ../../src/deh_defs.h:21,
                 from deh_bexstr.c:22:
../../src/doomtype.h:113:5: error: cannot use keyword ‘false’ as enumeration constant
  113 |     false,
      |     ^~~~~
../../src/doomtype.h:113:5: note: ‘false’ is a keyword with ‘-std=c23’ onwards

Ага, значит ошибка была на этапе компиляции. После многих лет обвинений в непонятных сообщениях об ошибке, GCC потратил несколько лет, чтобы значительно улучшить ситуацию, и теперь, основываясь на ошибке и последующем сообщении, было сразу понятно, в чем дело. В коде движка chocolate‑doom объявляет собственный тип boolean:

#if defined(__cplusplus) || defined(__bool_true_false_are_defined)
 
typedef bool boolean;
 
#else
 
typedef enum 
{
    false, 
    true
} boolean;
#endif

Итак, когда исходники обрабатываются как C++, используется тип bool из C++, а если как C — используется кастомный тип. Это прекрасно работало со старыми стандартами C, поскольку в C89 не было типа boolean как такового, а введенный в C99 тип назывался _Bool — хотя была возможность включить заголовок <stdbool.h>, для удобства объявлявший макросы bool, true и false. Начиная с C23, _Bool переименован в bool, а троица bool, true и false теперь полноценные ключевые слова.

Окей, логично, что кастомный тип boolean конфликтует с ключевыми словами false и true — но напрашивается вопрос, почему ошибка возникла только сейчас, если пару месяцев назад все работало нормально? Почему сборка идет с C23?

Вполне стандартное изменение

Как я уже писал несколько параграфов тому назад, одна из целей массовой пересборки — убедиться, что дистибутив может собираться на современных компиляторах. Если вы посмотрите на историю версий GCC, то заметите, что на протяжении последних 10 лет календарь релизов придерживается последовательности одного мажорного релиза в год где‑то между апрелем и маем, что неплохо вписывается в календарь релизов Fedora Linux, делая ее отличной площадкой для тестирования пре‑релизов GCC на большой и разнообразной кодовой базе.

Этот раз не исключение — GCC 15.0.1 оказался в Fedora Rawhide (версия «вечной альфы» для разработки) за несколько часов до начала массовой пересборки. В ченджлоге новой версии оказалось изменение, относящееся к моей проблеме — стандарт C по умолчанию был изменен с -std=gnu17 на -std=gnu23. И действительно, если вы вернетесь назад и внимательно посмотрите на команду вызова компилятора, вы заметите, что в ней нет опции -std=, явно задающей стандарт.

Итак, что теперь? Немного поразмыслив, я пришел к трем вариантам:

  1. Явно задать стандарт C, указав C17 или старше, чтобы код собирался с кастомным типом boolean.

  2. Поправить код, изменив #ifdef так, чтобы компилятор использовал встроенный тип bool при использовании C23.

  3. Переименовать варианты enum в False и True и изменить все места, где они используются.

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

--- a/src/doomtype.h
+++ b/src/doomtype.h
@@ -100,9 +100,9 @@
 
 #include <inttypes.h>
 
-#if defined(__cplusplus) || defined(__bool_true_false_are_defined)
+#if defined(__cplusplus) || defined(__bool_true_false_are_defined) || (__STDC_VERSION__ >= 202311L)
 
-// Use builtin bool type with C++.
+// Use builtin bool type with C++ and C23.
 
 typedef bool boolean;

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

Движок делает бум

Мое предложение послужило источником размышлений среди меинтейнеров, которые в конце концов решили, что лучшим решением будет объявить проект написанным на C99. Один из них создал еще один pull request:

--- a/src/doomtype.h
+++ b/src/doomtype.h
@@ -99,12 +99,11 @@
 // standard and defined to include stdint.h, so include this. 
 
 #include <inttypes.h>
+#include <stdbool.h>
 
 #if defined(__cplusplus) || defined(__bool_true_false_are_defined)
 
-// Use builtin bool type with C++.
-
-typedef bool boolean;
+typedef int boolean;
 
 #else

Добавленный #include имеет смысл — используя C99, <stdbool.h> гарантированно существует и дает нам bool, true и false. Но вот изменение в typedef было интересным — оно означало, что несмотря на переход к C99, код все еще будет хранить булевы значения в виде целых чисел вместо использования полноценного типа bool/_Bool. Это привело к следующей короткой дискуссии:

<turol>
Explicitly setting the C standard is fine but we shouldn't change the includes or boolean type.

<fabiangreffrath>
Chocolate Doom doesn't even start if we keep 
#typedef bool boolean.

<suve>
What do you mean by "doesn't even start"? Crashes right away? Including 
<stdbool.h> adds the #define bool _Bool macro, so keeping typedef bool boolean means you're using the C99 _Bool type.

<fabiangreffrath>
It fails with: 
R_InitSprites: Sprite TROO frame I has rotations and a rot=0 lump.

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

if (sprtemp[frame].rotate == false)
    I_Error ("R_InitSprites: Sprite %s frame %c has rotations "
         "and a rot=0 lump", spritename, 'A'+frame);

Так что же здесь происходит? Я не буду дальше приводить куски кода и просто кратко перескажу:

  • Код находится в функции R_InstallSpriteLump().

  • frame — аргумент функции. И хоть он и не помечен как const, он не меняется внутри функции.

  • sprtemp — глобальная переменная, хранящая массив из 29 структур spriteframe_t.

  • Поле .rotate этой структуры имеет тип boolean.

Хорошо. Проблема следующая: когда boolean — кастомный enum‑тип, код работает как и задумано и условие ошибки имеет значение false; но при использовании _Bool оно принимает значение true и приводит к выходу с ошибкой. Звучит сомнительно… Давайте попробуем потыкать память с помощью gdb. Начнем с рабочей версии.

$ gdb ./build/src/chocolate-doom--enum
[...]
(gdb) break src/doom/r_things.c:138
Breakpoint 1 at 0x44f822: file chocolate-doom/src/doom/r_things.c, line 138.
(gdb) run
[...]
Thread 1 "chocolate-doom" hit Breakpoint 1, R_InstallSpriteLump (lump=1242, frame=0, rotation=1, flipped=false) at chocolate-doom/src/doom/r_things.c:138
138     if (sprtemp[frame].rotate == false)
(gdb) print sprtemp[frame]
$1 = {rotate = (true | unknown: 0xfffffffe), lump = {-1, -1, -1, -1, -1, -1, -1, -1}, flip = "\377\377\377\377\377\377\377\377"}
(gdb) step
142     sprtemp[frame].rotate = true;

Окей, значение нашего boolean содержит… эм, чего? Нотация поначалу меня немного запутала, но похоже что из‑за того, что тип boolean — enum, gdb пытается помочь, показав как можно получить значение, используя битовое ИЛИ с некоторыми допустимыми значениями enum. (Вот только это не особо работает в нашем случае.) Как бы то ни было, true это 1 и использование битового ИЛИ с «неизвестным» значением дает нам 0xffffffff, что толкает на мысль о том, что поле инициализируется посредством записи -1 на одном из более ранних этапов. Так и есть, посмотрев бэктрейс и вызывающую функцию, мы найдем вызов memset (sprtemp,-1, sizeof(sprtemp));.

Отлично, как минимум одной загадкой меньше. Возвращаясь к брейкпоинту, мы видим простое сравнение между 0xffffffff и 0x0, что дает нам false. Логично. Давайте тогда посмотрим на версию _Bool.

$ gdb ./build/src/chocolate-doom--bool
[...]
(gdb) break src/doom/r_things.c:138
Breakpoint 1 at 0x44f822: file chocolate-doom/src/doom/r_things.c, line 138.
(gdb) run
[...]
Thread 1 "chocolate-doom" hit Breakpoint 1, R_InstallSpriteLump (lump=1242, frame=0, rotation=1, flipped=false) at chocolate-doom/src/doom/r_things.c:138
138     if (sprtemp[frame].rotate == false)
(gdb) print sprtemp[frame]
$1 = {rotate = 255, lump = {-1, -1, -1, -1, -1, -1, -1, -1}, flip = "\377\377\377\377\377\377\377\377"}

Размер boolean теперь меньше — 1 байт вместо 4. Не считая этого различия, все так же, как и раньше — поле инициализируется со значением -1 и программа собирается выполнить сравнение 255 == 0.

(gdb) step
139     I_Error ("R_InitSprites: Sprite %s frame %c has rotations "

Эм…То есть… ч е г о?

Время копнуть глубже

Даа, это было неожиданно. Подумав, что возможно есть какое‑то взаимодействие с другой частью программы, которую я не вижу, я попробовал изолировать поведение для воспроизведения.

#include <stdio.h>
#include <string.h>
 
#ifdef DUPA
#include <stdbool.h>
typedef bool boolean;
#else
typedef enum {
    false,
    true
} boolean;
#endif
 
boolean some_var[30];
 
int main(void) {
    memset(some_var, -1, sizeof(some_var));
    some_var[0] = false;
    some_var[1] = 500;
 
    for(int i = 0; i <= 2; ++i) {
        if(some_var[i] == false) printf("some_var[%d] is false\n", i);
        if(some_var[i] == true) printf("some_var[%d] is true\n", i);
        printf("value of some_var[%d] is %d\n", i, some_var[i]);
    }
    return 0;
}

И да, поведение совпадало с наблюдаемым в игре:

$ gcc -o booltest ./booltest.c 
$ ./booltest 
some_var[0] is false
value of some_var[0] is 0
some_var[1] is true
value of some_var[1] is 1
value of some_var[2] is -1
$ gcc -DDUPA -o booltest ./booltest.c 
$ ./booltest 
some_var[0] is false
value of some_var[0] is 0
some_var[1] is true
value of some_var[1] is 1
some_var[2] is false
some_var[2] is true
value of some_var[2] is 255

Почему‑то установка значения _Bool равного 255 делало так, что оно было true и false одновременно. Ответов на этот вопрос в C‑коде было не получить, а значит пришло время покопаться в том, как это выглядит на уровне ассемблера. Для этого я использовал Godbolt compiler explorer.

Ага! Сгенерированные инструкции немного отличались. Это было бы отличной новостью, если бы я не упустил одну маленькую деталь: я вообще ничего не знаю о x86-ассемблере.

К счастью, у меня есть такая небольшая удобная штука как интернет, где можно найти ответы на многие вопросы бытия. Просмотрев несколько источников, я начал переводить инструкции ассемблера на польский язык (но для вашего удобства тут они будут на русском). Во всех четырех сценариях код начинается с загрузки some_value[i] в регистр eax.

boolean - enum, проверка на true

  1. CMP eax, 1
    Тут все просто — идет сравнение значения регистра с 1. Если аргументы равны, устанавливается «нулевой флаг» (ZF); в противном случае флаг обнуляется.

  2. JNE .L4
    Идет проверка ZF и если он не задан, происходит прыжок, пропуская вызов printf().

Окей, значит поведение обратное — вместо «сделай что‑то когда переменная равна 1» я получаю «пропусти штуки, когда переменная не равна 1» — но в случае ассемблера это вполне логично. В большинстве своем код совпадает с ожидаемым.

boolean - enum, проверка на false

  1. TEST eax, eax
    Тут вычисляется побитовое И регистра с самим собой. Если полученное значение равно 0, устанавливается ZF, если нет — флаг снимается.

  2. JNE .L3
    Если ZF не задан, происходит прыжок и вызов printf() пропускается.

На этот раз код ассемблера немного страннее, поскольку вместо «сравнения с 0» мы получаем «выполни побитовое И значения и самого себя» — что скорее всего некая микрооптимизация. Тем не менее, код делает что должен — пропускает вызов printf() для любого ненулевого значения.

boolean - _Bool, проверка на true

  1. TEST al, al
    Тут опять вычисляется побитовое ИЛИ значения и себя и задается ZF если результат равен 0. al это восьмибитовый регистр, который соответствует 8 нижним битам eax — это происходит потому что значения boolean теперь имеют размер в 1 байт вместо 4.

  2. JE .L4
    Прыжок, если ZF задан.

Вот тут уже интереснее! Похоже, что в этой версии логика не «пропусти, когда переменная не равна 1», а «пропусти, когда значение равно 0».

boolean - _Bool, проверка на false

  1. XOR eax, 1
    Здесь выполняется побитовое исключающее ИЛИ значения и 1. Первый аргумент используется для сохранения результата. По сути, это поменяет значение нижнего бита булева значения — с 0 на 1 или с 1 на 0.

  2. TEST al, al
    Опять же, выполняется побитовое ИЛИ и если результат равен 0, выставляется ZF.

  3. JE .L3
    Прыжок, если ZF задан.

Вот оно что. То есть он переворачивает нижний бит и затем выполняет прыжок, если значение после этого равно 0. А значит логика становится «пропусти, когда переменная равна 1».

Подводя итог

Когда boolean это enum, компилятор делает то, что я и ожидал — условие == false проверяет, равно ли значение 0, а == true в свою очередь — равно ли оно 1.

Но когда boolean это _Bool, условие == false превращается в != 1, а == true в != 0, что в контексте булевых значений абсолютно логично. Но это также дает нам забавное поведение для значения в 255 — поскольку 255 одновременно не 1 и не 0, оба условия проходят!

А у них и правда есть такие правила?

Теперь, когда мы знаем, что происходит, осталась последняя загадка - узнать, почему это происходит. Ну то есть, указание невалидного значения типа и получение странного результата вполне в рамках ожиданий, а учитывая что речь о C, я был вполне уверен, что столкнулся с всеми любимой стороной языка - неопределенное поведение. Компиляция с UBSan быстро подтверждает данную гипотезу.

$ gcc -DDUPA -fsanitize=undefined -o booltest ./booltest.c 
$ ./booltest 
some_var[0] is false
value of some_var[0] is 0
some_var[1] is true
value of some_var[1] is 1
booltest.c:22:14: runtime error: load of value 255, which is not a valid value for type '_Bool'
booltest.c:23:14: runtime error: load of value 255, which is not a valid value for type '_Bool'
some_var[2] is true
booltest.c:24:69: runtime error: load of value 255, which is not a valid value for type '_Bool'
value of some_var[2] is 1

На этом можно было бы закончить, но мне все же интересно, какая часть стандарта нарушается кодом выше. Я нашел PDF‑версию стандарта C99 и начал пробегаться глазами. К сожалению, в приложении J «Portability issues», описывающим много разных способов столкнуться с незаданным, неопределенным или зависящим от реализации поведением, не содержит информации насчет невалидных значений _Bool.

Еще немного покопавшись, я нашел ответ в разделе 6.2.6, «Representation of types». Параграф 6.2.6.1.5 говорит следующее:

Certain object representations need not represent a value of the object type. If the stored value of an object has such a representation and is read by an lvalue expression that does not have character type, the behavior is undefined.

Это подходит под мою ситуацию - был объект _Bool с невалидным значением _Bool и он читался как часть выражения, не обрабатывающего его как char. Так что да, код вполне себе попадал в зону UB; на счастье на этот раз демоны меня пощадили.

Комментарии (4)


  1. Jijiki
    30.01.2025 16:15

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


    1. lorc
      30.01.2025 16:15

      Какая связь между оригинальным Doom и TES? Их делали разные люди, в разное время, используя разные технологии.


  1. JBFW
    30.01.2025 16:15

    Ну логично же:
    0x00 - false
    0x01 - true
    0xFF - not false, not true


    1. lorc
      30.01.2025 16:15

      Да, но только для _Bool. Для других целых типов все проще: 0 == false, все что не 0 == true.