Rogue — возрождение кода из 1981 года

Игра Rogue знаменита не только тем что породила и вдохновила огромное количество «потомков» — от визуально схожих Moria и NetHack до графически продвинутых вроде DIablo. Знаменита она ещё и тем что является одной из самых сложных игр для прохождения (в этом классе она гораздо интеллектуальнее чем Flappy Bird). Не уверен что вы найдете даже по форумам человека который скажет что проходил её (не читеря с файлами сохранения).

Я заметил что современная опенсорсная версия (например доступная в пакетах для разных Linux и BSD) отличается от той, например, что была портирована коммерчески под ДОС где-то в 80е. Заметно отличаются монстры — чуть ли не со 2 уровня уже можно напороться на Ледяного Монстра который не только лишает подвижности но ещё и активно дамажит. Как будто и без того сложная игра стала ещё сложнее!

Дело в том что ранний код Rogue изначально не был доступен публично — кроме того авторы опенсорсной версии хотели избежать возможных нарушений прав (т.к. существовали уже коммерческие порты).

Чтобы разобраться я решил скомпилировать и запустить одну из самых старых доступных версий — посмотреть отличия — и вообще как что устроено. Здесь я расскажу о возникших мелких проблемах (любопытно м.б. для программистов на С) — и возможностях этой самой оживлённой версии (её я выложил на гитхаб — каждый может взять и погонять).

Об игре Rogue

Немного напомню в чем геймплей и изюминка игры. Это пошаговая RPG без большого количества наворотов, в которой вы перемещаете героя отмеченного символом @ в псевдографическом лабиринте.

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

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

Свойства магических предметов неизвестны пока вы их не попробуете или не идентифицируете (если уже нашли и идентифицировали свиток идентификации). Поэтому легко жахнуть зелье ослепления и на 1000 ходов примерно тупо тыкатья в стены (если раньше не загнётесь с голоду или напоретесь на слишком крутого монстра).

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

Смерть персонажа — окончательна (permanent death). Приходится начинать сначала. Вместе с очень тугими монстрами на нижних уровнях это нередко ведёт к фрустрации.

Архив с версиями

Наверное энтузиастов собирающих разные версии существует больше одного — я порывшись по сайтам нашёл RogueArchive by Britzl — тут главное не запутаться, многие версии 1.x указанные здесь подразумевают коммерчески выпущенные, позже ранних 3.x появлявшихся в ARPANET. Кроме того многие не содержат исходного кода.

Я взял from_bsd_usenix87_rogue3.6.zip — по-видимому это одна из самых ранних у которой доступен исходный код. Ей уже 44 года.

Затруднительно точно сказать насколько это версия «авторская» — к 1981 году над проектом уже кроме исходного автора работали 1 или 2 сотоварища. Код нельзя сказать чтобы блещет аккуратностью, но учитывая что они были студентами-энтузиастами а не профи-разработчиками, он не так плох. Хотя местами просто какие-то непоследовательные операции — но это касается не игровой логики, а функционала инициализации и деинициализации, особенно в разных ситуациях окончания игры.

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

При первой попытке скомпилировать вы увидите что код написан в стиле K&R (типы аргументов функции описываются после списка аргументов), табуляции перемежаются с пробелами, глобальные переменные разбросаны достаточно нестрого да многие ещё и переобъявлены в заголовочном файле (вместо extern).

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

Проблемы на уровне компиляции

Список коммитов можно смотреть в репозитории чтобы лучше ориентироваться «по шагам».

Первым делом я добавил простенький build.sh файл для сборки. Код естественно требует библиотеку curses а также crypt, но вызовы ко второй я позже отбросил — для наших исследовательских целей она не важна (использовалась для шифрования файлов сохранения, таблицы топ-20 — и для пароля wizard-режима). Используется стандарт C89 чтобы не менять объявления функций в K&R стиле.

Следующим шагом удалил несколько файлов не относящихся к коду, исторического характера. Среди них удалён файл сообщающий о починке «бага со стрелой» — а также файл curses.h — он по-видимому мог использоваться в системах где библиотека curses находилась в недостаточно современном состоянии (предположение).

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

Одна из первых «ломающих» ошибок — макрос для символа порождаемого нажатием буквенной клавиши вместе с клавишей Ctrl. Этот модификатор обычно заставлял клавиши выдавать коды от 1 до 26. В исходной версии макрос для этого выглядит так:

#define CTRL(ch) ('ch' & 037)

А используется так:

if (ch == ESCAPE || ch == CTRL(G))

То есть вы передаёте параметром макроса букву — а он её подставляет внутрь одинарных кавычек. Не уверен, но кажется препроцессор в современных версиях Си так не умеет 🙂 Поэтому заменил по коду регэкспом чтобы кавычки добавлялись при обращении к макросу а не внутри него.

Второй проблемой было что авторы часто не включали нужные для той или иной стандартной функции заголовочные файлы. Части из них они переобъявили в rogue.h, а некоторые объявляли даже внутри собственных функций, непосредственно перед использованием. И не всегда эти объявления совпадают с современными. Например вот декларация нескольких функций из rogue.h:

char* malloc(), getenv(), unctrl(), tr_name(), new(), sprintf();

Тут напомним что по «понятиям» того времени функция по дефолту возвращает int (его можно не указывать) а параметры при декларации можно не указывать вообще. В общем все эти декларации понадобилось где-то поправить а где-то просто удалить.

Функцию remove для удаления монстра пришлось переименовать в removemon чтобы она не путалась со стандартной.

Далее обнаружилось что некоторые глобальные переменные в виде массива структур авторы объявили прямо в rogue.h в виде массива без размера. В наше время это так не работает:

struct h_list {
    char h_ch;
    char *h_desc;
} helpstr[];

Это несложно было поправить разделив объявление самого массива (extern) от объявления структуры.

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

signal(SIGEMT, auto_save);

Я хотел минимально менять авторский код поэтому просто окружил эту строчку в #ifdef SIGEMT — хотя можно было вообще выпилить сигналы к лешему, опять же, поскольку у нас исследовательские цели.

Несколько мелких правок — выкинул вызов функции sbrk (не особо нужно, и проще чем поправлять), убрал параметры в #undef — оказывается синтаксис препроцессора используемого авторами позволял и такое — и системные обращения к _tty — из тех же соображений что для изучения игры они не несут ценности, поэтому разбираться на что их заменять — очень нудно.

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

Как я упоминал, была проблема с тем что часть переменных объявлена в каком-нибудь C-файле но продублировано объявление и в заголовочном rogue.h — современный линкер ругается на дублирование везде где заголовочный файл включается — так что их пришлось нудно заменять на extern-ы поштучно (т.к. это касается не всех переменных).

Некоторые другие мелочи — приведения типов, избавление от функций шифрования (они точно не помогут изучать функционал а только помешают) и прочее — вошли в пару коммитов с названиями various discrepancies — о них уже подробно не будем. После этого шага мы получили успешную компиляцию — и исполнимый файл на выходе. Заработает ли он?

Ошибки во время выполнения

Конечно, исполнимый файл сразу падал жалостливо с segfault. Тут как обычно, можно либо дебаггером начать искать — либо «отладкой принтами». Я использовал второй способ с некоторыми улучшениями (в т.ч. печать в отдельный файл) т.к. в дальнейшем нужно было отыскивать проблемы которые возникали в достаточно неожиданных местах, это уж дебаггером по шагам нудно выискивать.

Вскоре проблема была найдена в инициализации списков зелий, свитков и т.п. — тут забавная проблема. Авторы создавали списки названий, например так:

static char *rainbow[] = {
    "Red",
    "Blue",
    ...
}

И при инициализации, когда нужно цвет сопоставить с каким-то конкретным типом объекта, уже обработанные цвета обозначали переводя первую букву в lowercase.

Если данные строковых литералов находятся в сегменте памяти защищённой от записи — конечно, выполнение падает. Но так оно было не во всех системах (наверняка в Borland C без проблем). Нужно либо копировать литералы в нормальный байтовый массив (дальше ещё будет это) — либо использовать временный массив флажков, что я и сделал для ясности.

После этого код наконец запустился настолько, что я увидел комнату в лабиринте с персонажем, монстром и каким-то стаффом. Ура-ура!

Вот только при атаке на монстра часто происходило падение. Тут уже пришлось поизучать код в fight.c, расставлять принты в функции для атаки на монстра и наоборот — и наконец стало ясно — дело в выводе некоторых сообщений.

Дело в том, что использование функций с переменным числом аргументов — оно было предусмотрено не только в C с самого начала, но и в его предшественниках, включая «дедушку» BCPL о котором я писал ранее. Однако оформление кода с ними и реализация доступа предсказуемо менялись в т.ч. по системным соображениям. Несколько функций где это использовалось (в основном в io.c для вывода сообщений) как раз и вызывали такие неожиданные крэши. В общем нужно было аккуратно поправить на использование va_list и va_start (в одной из функций ещё раньше было поправлено — оказалось удобно использовать vsprintf вместо всего что там нагородили).

Один ужасно болезненный баг проявлялся редко и проверить, понять его было затруднительно. Выражался он в зависании после некоторых действий. Например после падения в ловушку-яму (принудительный спуск на следующий уровень).

Выглядело так будто программа работает, но ввод перестаёт функционировать. Оказалось всё дело в паре вызовов raw() / noraw() — они влияют на то как именно воспринимаются символы с клавиатуры, с буферизацией или без. Эта пара по замыслу автора очищала буфер. Проще всего оказалось удалить их кроме одного случая.

Тут уже игра стала вполне играбельной — но завершение её выглядело совершенно невнятно. Там была напутана и последовательность действий (например экран с могилкой не давал паузы и сразу закрывалась псевдографическая система) — и год печатался неправильно (19125) и главное не работал файл для записи таблицы рекордов.

Его по идее нужно всего лишь прочитать, добавить (вставка в упорядоченный массив) результат погибшего игрока — и записать обратно. Изначально это делалось странно — использовалась функция open (знаете, работа с файлами через fcntl.h вместо stdio.h) но тут же переоткрывался с fdopen для записи.

Проще всего это оказалось заменить на fopen/fclose и fwrite для бинарных данных.

Наконец нашёлся очень редкий баг — связанный со скроллом genocide — такой свиток 1 раз из 100 и позволяет изгнать из игры (включая последующие уровни) какой-либо тип монстров (правда, в зависимости от уровня).

С ним дело оказалось опять же с модификацией константных строк:

static char* lvl_mons = "KJBSHEAOZGLCRQNYTWFIXUMVDP";

Эта строка например содержит какие монстры могут появляться в игре. Геноцид заменяет нужную букву (тип монстра) на пробел. Заменить в константном литерале на многих платформах нельзя, поэтому фикс заключался в правке:

static char lvl_mons[] = "KJBSHEAOZGLCRQNYTWFIXUMVDP";

Других багов пока не обнаружено — играбельная версия на гитхабе именно в таком состоянии. Компилируется под Ubuntu и FreeBSD (но curses надо установить самостоятельно — например sudo apt install libncurses-dev).

Проблема сохранения

На данный момент оно не работает. Дело в том что у авторов сохраняется просто целиком сегмент с данными, где все глобальные переменные лежат. Хотя это и можно провернуть и в нынешних компиляторах — но это трудно сделать портируемым.

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

Сохранение сейчас нам пригодилось бы только ради целей почитерить. Но этой цели можно достичь с использованием wizard-режима, о чем чуть позже.

Одно из дополнительных препятствий — в том что карта лабиринта использует размеры экрана (COLS и LINES из curses) — и с нынешними «растягиваемыми» консольными окошками в большинстве графических оболочек может быть ситуация что вы попытаетсь загрузить игру уже в другой геометрии чем сохранили. Наверное можно выдавать предупреждение и т.п. — но это требует дополнительных интеллектуальных усилий, поэтому отложим как несрочное.

Замечание по коду

Авторы активно используют switch — местами даже там где не надо — но что интересно, они задефайнили вместо case и default два новых слова when и otherwise — они отличаются наличием встроенного break для предшествующей ветки.

Замечания по игре — и потенциальные улучшения

Для движения по 8 направлениям используется несколько архаичная раскладка. Клавиши hjkl позволяют переместиться влево-вверх-вниз-вправо, точно так как в vim — для диагональных перемещений рядом с ними yunm. Думаю по тем временам это было довольно стандартно (и игры типа moria до сих пор позволяют переключить режим клавиатуры).

Справки по командам встроенной нет, но её легко подсмотреть в commands.c.

Неудобно что нет также функции посмотреть последнее сообщение (промелькнувшее например пока бежал и подобрал какой-то предмет или тебя стукнул сбоку монстр). Такое пожалуй стоит добавить позже.

Wizard-режим

Опция которой большинство игравших в rogue вероятно никогда не видели — режим в котором можно посмотреть лабиринт, монстров или прокачать персонажа.

Включается нажатием Ctrl-P и вводом пароля. Пароль можно найти (или поменять) в rogue.h — и вам становятся доступны несколько дополнительных команд — наверное самые интересные эти:

  • посмотреть экран размещения монстров

  • посмотреть экран лабиринта

  • увеличить уровень

  • уменьшить уровень

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

Заключение

Как я сказал, получившаяся версия вполне играбельна. Если пожелаете — потестируйте её и сравните впечатления с «дефолтной». Мне кажется она несколько легче, как минимум на первых 10 уровнях. Из наиболее заметных отсутствующих фич — изредка возникающие «ямы с монстрами» — комнаты, где плюнуть некуда от монстров — это наверняка позднее дополнение.

Насчет того что играется как будто легче — вспомните упомянутого ледяного монстра (символ I) в современной версии — как я говорил, он обломает треть ваших заходов в игру на 2-4 уровне и вскоре вы предпочтёте его не трогать или расстреливать издали. В оригинальной версии его прототипом является летающий глаз (символ E) — он хотя и обездвиживает, но урона не наносит. То есть проблема с ним может быть только если рядом пасётся ещё какой-то монстр.

Вообще файлы с монстрами, стаффом и оружием например посмотреть интересно хотя бы ради того чтобы знать что встречается в игре и насколько оно относительно сложно. Вот например список монстров (он в init.c) — их 26, по количеству и по порядку букв алфавита. Самый зверский из них — дракон. Его попросту лучше не встречать или хотя бы иметь при себе свиток телепорта на этот случай.

Rogue — возрождение кода из 1981 года

На этой позитивной ноте попрощаемся. У меня есть какие-то туманные планы на создание серверной игры на основе этой версии — если они когда-нибудь потерпят успех, её можно будет попробовать без собственноручной компиляции. Близкую же к ней досовскую версию вы конечно легко найдёте на abandonware и запустите в DosBox.

 

Источник

Читайте также