Должен признать, у меня есть некая особая любовь к классическому 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=
, явно задающей стандарт.
Итак, что теперь? Немного поразмыслив, я пришел к трем вариантам:
Явно задать стандарт C, указав C17 или старше, чтобы код собирался с кастомным типом
boolean
.Поправить код, изменив
#ifdef
так, чтобы компилятор использовал встроенный типbool
при использовании C23.Переименовать варианты
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 keepingtypedef 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
CMP eax, 1
Тут все просто — идет сравнение значения регистра с 1. Если аргументы равны, устанавливается «нулевой флаг» (ZF); в противном случае флаг обнуляется.JNE .L4
Идет проверка ZF и если он не задан, происходит прыжок, пропуская вызовprintf()
.
Окей, значит поведение обратное — вместо «сделай что‑то когда переменная равна 1» я получаю «пропусти штуки, когда переменная не равна 1» — но в случае ассемблера это вполне логично. В большинстве своем код совпадает с ожидаемым.
boolean - enum, проверка на false
TEST eax, eax
Тут вычисляется побитовое И регистра с самим собой. Если полученное значение равно 0, устанавливается ZF, если нет — флаг снимается.JNE .L3
Если ZF не задан, происходит прыжок и вызовprintf()
пропускается.
На этот раз код ассемблера немного страннее, поскольку вместо «сравнения с 0» мы получаем «выполни побитовое И значения и самого себя» — что скорее всего некая микрооптимизация. Тем не менее, код делает что должен — пропускает вызов printf()
для любого ненулевого значения.
boolean - _Bool, проверка на true
TEST al, al
Тут опять вычисляется побитовое ИЛИ значения и себя и задается ZF если результат равен 0.al
это восьмибитовый регистр, который соответствует 8 нижним битамeax
— это происходит потому что значенияboolean
теперь имеют размер в 1 байт вместо 4.JE .L4
Прыжок, если ZF задан.
Вот тут уже интереснее! Похоже, что в этой версии логика не «пропусти, когда переменная не равна 1», а «пропусти, когда значение равно 0».
boolean - _Bool, проверка на false
XOR eax, 1
Здесь выполняется побитовое исключающее ИЛИ значения и 1. Первый аргумент используется для сохранения результата. По сути, это поменяет значение нижнего бита булева значения — с 0 на 1 или с 1 на 0.TEST al, al
Опять же, выполняется побитовое ИЛИ и если результат равен 0, выставляется ZF.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; на счастье на этот раз демоны меня пощадили.
Jijiki
дум классная, там еще несколько таких есть, даггерфол и арена, ультима , а вот Морровинд это некстгеныч после граффики аля дум), потом появился Обливион всё еще кастомный некстгеныч, апогеем стал Скайрим как мне кажется, но Скайрим это более глубокое погружение в энто дело ), но просматривая технологически рост студии в разрезе используемых технологий получается весь смак на ранних этапах это как энциклопедия эволюции )))
lorc
Какая связь между оригинальным Doom и TES? Их делали разные люди, в разное время, используя разные технологии.