Maniac Mansion - это классическая приключенческая игра с интерфейсом point and click. Она вышла в конце 80х годов для платформ Commodore 64, Apple II, Atari ST, Amiga, IBM PC и NES. Популярный в наших краях ZX Spectrum этой игры не увидел.
Может быть дело в том, что графический режим ZX Spectrum поддерживает только два цвета на знакоместо. А может, потому что аппаратных спрайтов там не было (хотя в Apple II их не было тоже).
Пришло время исправить эту несправедливость и портировать Maniac Mansion на улучшенный ZX Spectrum - ZX Spectrum Next.
Что за ZX Spectrum Next?
Это "переизданный" в 2017 году ZX Spectrum, получивший новые возможности. Не совсем труъ, потому что процессор реализован в FPGA, как и звуковой и графический сопроцессоры. Но зато умеет переключаться в режимы разных клонов ZX Spectrum, включая знаменитый Пентагон.
Другие преимущества ZX Spectrum Next:
Процессор можно разогнать до 28 МГц;
1 Мб ОЗУ;
Графика с попиксельными цветами;
Тайловый и спрайтовый сопроцессоры;
Дополнительные звуковые "чипы";
Возможность работать с файлами на SD-карте;
Видеовыходы HDMI и VGA;
Всё упаковано в корпус-клавиатуру типа ZX Spectrum+ (серьёзное преимущество перед остальными FPGA-реализациями).
Свой экземпляр я заказывал ещё через первую кампанию на Kickstarter. Конструкция компьютера и правда хорошая, но нашёлся и недостаток - нет кнопки отключения питания. Если просто выдернуть блок питания, то компьютер продолжает "подпитываться" через HDMI-кабель. Поэтому когда отлаживаешь программу, постоянно переставляя SD-карту, приходится также дёргать и видеокабель.
Maniac Mansion изнутри
Создание игры программисты LucasArts начали с собственного скриптового движка. Не на ассемблере же персонажей программировать. В итоге он оказался таким удачным, что получил собственное название SCUMM (Script Creation Utility for Maniac Mansion) и был использован позднее во множестве игр, включая знаменитую серию Monkey Island.
Было несколько версий игры под разные платформы. Версии под NES не слишком повезло, она подверглась цензуре Nintendo из-за семейного характера консоли. Но взорвать хомяка в этой версии всё-таки можно. Так как графика в NES рисуется аппаратными графическими ускорителями, я решил взять именно её. Ведь ZXNext тоже что-то похожее поддерживает. Возиться с пиксельной графикой Commodore 64 не очень хотелось, вдруг быстродействия не хватит, программа ведь будет на Си.
Теоретически, можно сделать свою прошивку для FPGA - эмулятор NES, а потом запускать там что угодно. Но мне хотелось запускать только Scumm-игры, поэтому я стал смотреть, как можно их портировать под архитектуру ZXNext.
На современных компьютерах игры на движке Scumm можно запускать с помощью эмулятора ScummVM. Это независимая открытая реализация такого движка и ещё кучи других. Не всегда точная, зато доступная для изучения.
Было два пути начать мой проект - разбираться с кодом ScummVM или дизассемблировать оригинальный Maniac Mansion. Дизассемблировать в принципе не очень просто (хотя бывает и весело). И с определением назначения некоторых переменных не справились даже авторы ScummVM:
Поэтому я решил разрабатывать эмулятор, используя наработки из ScummVM. Была надежда, что можно будет копировать большие куски и просто перекомпилировать их. Ну и потом поддержать другие игры, конечно. Оказалось всё не так просто: современные версии ScummVM написаны на C++ и там куча условий для других игр и версий движка. Так что к портированию пришлось подойти творчески.
Скрипты
Скриптовый язык Scumm развивался с 1987 по 1998 годы, пережив десяток версий и пару лицензированных форков. Отказались от него лишь для разработки трёхмерных игр.
Чтобы реализовать скриптовый движок, надо было поддержать примерно 82 команды (примерно, потому что я какие-то мог пока что упустить), большинство из которых довольно простые: установить переменную, прочитать переменную, прервать выполнение, перейти если установлен флаг... Но были и сложные. Например, "сменить комнату". Она задействует не только скриптовые переменные, но запускает скрипт выхода из старой комнаты, инициализирует объекты, загружает спрайты и тайлы фона, запускает скрипт входа в новую комнату. В завершение (скриптов никогда не бывает слишком много) запускается ещё один скрипт с захардкоженным номером 5.
Тип команды определяется всего одним байтом (и даже в нём может быть несколько битов, которые параметризуют основное поведение). Дальше могут записываться параметры. Иногда это просто пара байт, а иногда целые строки, когда нужно вывести текст.
Вот декомпилированный скрипт входа в одну из комнат. Семантику констант-параметров узнать будет довольно сложно:
Entry {
[0000] lights(2); // Normal Lights
[0002] loadScript(121);
[0004] if (Var[18] == 0) { // if Meteor Location == Meteor Room
[0009] putActorInRoom(16,51); // put meteor in meteor room
[000C] putActor(16,40,50);
[0010] animateActor(16,0,1);
[0014] VAR_RESULT = isScriptRunning(37);
[0017] if (VAR_RESULT == 0) { // if not running Cutscene: Meteor Police Arrived in Dungeon
[001C] loadCostume(20);
[001E] lockCostume(20);
[0020] startScript(123); // start Meteor: Fire Radiation
[0022] }
[0022] }
[0022] if (!getState08(143)) { // if Switch switched
[0026] startSound(59); // sound Machine power down
[0028] loadScript(121); // start Dr Fred: Free of machines control
[002A] }
[002A] loadScript(123); // start Meteor: Fire Radiation
[002C] stopObjectCode();
}
Судя по руководству 1991 года по Scumm, исходный текст таких скриптов выглядел довольно культурно, с именованными объектами и переменными:
В общем получилась не очень сложная виртуальная машина. У неё 800 слов памяти (нумерованных переменных) и умение выполнять несколько скриптов параллельно. Но от самой виртуальной машины толку мало. Управляет она игровыми объектами, графикой и музыкой.
Графика ZX Spectrum Next
В ZX Spectrum Next есть несколько видов графики. Часть заимствована из разных клонов ZX Spectrum, часть оригинальные:
Стандартный экран ZX Spectrum (256x192, атрибуты по знакоместам).
Экран Timex (512x192, монохромный).
LoRes (128x96, выбор из 16 цветов для каждого пикселя).
Тайловая карта, привязанная к знакоместам (640/320x256, палитра из 256 цветов).
Спрайты, не привязанные к знакоместам (128 спрайтов 16x16, палитра из 256 цветов).
Layer2 - режимы с отдельными цветами для каждого пикселя (256x192/320x256 - 256 цветов, 640x256 - 16 цветов).
Отображение спрайтов не зависит ни от чего, а тайловая карта, например, может показываться поверх стандартного экрана ZX Spectrum или под ним (для этого есть всякие режимы прозрачности).
Для графических квестов нам достаточно уметь рисовать фон в одном из режимов и подвижные объекты, составленные из спрайтов.
Тайлы для фона
Фон на экранах в оригинальном MM для NES рисуется с помощью тайлов. В обоих случаях используются тайлы 8x8 пикселей. Посмотрим, какие для них есть ограничения, чтобы узнать, получится ли рисовать экраны от NES на ZXNext.
ZXNext поддерживает два формата тайлового фона: 40x32 (320x256 пикселей) и 80x32 (640x256 пикселей). Тайлы состоят из 8x8 пикселей, каждый из которых можно раскрасить в один из 16 цветов.
Одновременно могут быть отображены только 256 или 512 разных тайлов (в зависимости от режима). Примерно то же самое мы видим и для NES: там из тайлов 8x8 состоит фон в 256x240 пикселей. Одновременно NES может использовать 256 тайлов.
Выходит, ничто не мешает нам отображать экраны от NES на ZXNext. Только надо разобраться с тем, как кодируются тайлы и их атрибуты.
Описание того, из каких тайлов составлен фон NES, хранится в "таблице имён" - nametable. Каждый тайл содержит пиксели четырёх "цветов". В итоге этого достаточно для четрёхцветной картинки. Но мы-то знаем, что графика в NES гораздо более красочная. Секрет в том, что атрибуты отображаемых тайлов хранятся в ещё одной таблице.
Таблица атрибутов хранит байт цвета для блоков 32x32 пикселя (то есть для 16 тайлов сразу). Это не цвета полностью, а только по 2 бита цвета для каждого из подблоков 2x2 тайла. 2 бита цвета в атрибутах, 2 бита цвета в тайле - всего на экране может быть 16 цветов одновременно.
Сравним это с возможностями ZXNext. Там цвет в тайлах кодируется 4 битами, а в карте тайлов хранится ещё 4 бита. Итого получается 256 цветов - с лихвой хватает для представления фоновой графики NES.
У описания тайлов в ZXNext нет фиксированного адреса, он задаётся через системный регистр. Но есть фиксированная область физической памяти, где это описание может храниться. Это страницы, которые обычно использует ULA в качестве экранной памяти. Обычно они расположены по адресам 0x4000-0x7fff и 0xc000-0xffff, а хранятся в физических страницах 10, 11, 14, 15. Но когда мы их заполняем, отображать их можно куда угодно. Тайловый движок всё равно пойдёт по физическим адресам, чтобы достать эти данные. У меня это страница 10. Точно так же работает память для карты тайлов. Я выделил под неё целую страницу 11.
Эти страницы отображаются по логическим адресам 0x4000 и 0x6000 только тогда, когда надо их заполнить. В остальное время в этих адресах выбраны страницы с данными Scumm.
Спрайты для движущихся объектов
Спрайты в NES тоже рисуются с помощью 256 паттернов, только они уже не привязаны к знакоместам на экране. Одновременно могут отображаться только 64 спрайта размером 8x8. Поэтому, чтобы нарисовать персонажей поверх фона, нужно всех их разбить на мелкие кусочки.
ZXNext может рисовать целых 128 спрайтов размером 16x16. Да ещё и масштабировать их умеет. Но паттернов для них будет только 64 для 8-битного цвета или 128 для 4-битного. То есть если не повезёт, мы не сможем рисовать движущихся персонажей без перезагрузки паттернов. NES же в таком случае сможет только спрайты менять.
Но зато у ZXNext есть очень полезная фича, когда несколько спрайтов составляют один объект на экране. Тогда один спрайт назначается "якорным", а координаты остальных рассчитываются относительно него уже сопроцессором по задаваемым смещениям. И чтобы всю эту кучу спрайтов переместить, нужно программно поменять координаты только у одного.
Все паттерны будут "недозаполненные", так как перегенерировать ресурсы, чтобы использовать спрайты 16x16 целиком, я не стал. Вместо этого в каждом из них используется квадратик 8x8, как в оригинале.
К примеру, вот из чего состоит картинка летящего метеорита. Три спрайта, смещённых друг относительно друга на некратное 8 число пикселей.
А вот персонажи на фоне Луны. И ещё курсор. Спрайты тоже неровно расставлены, так что перекодировать их в формат 16x16 было бы не слишком легко.
Распаковка ресурсов
Ресурсы игры я читаю из файлов на диске (то есть, на SD-карте). Как они туда попали? Есть утилита, которая достаёт отдельные "комнаты" из дампа картриджа. Комнаты - это не только реальные локации игры, но и просто хранилища ресурсов. Например, "комната" с нулевым номером содержит таблицы номеров комнат и смещений, где искать все остальные ресурсы.
Ресурсы в комнатах это скрипты и "костюмы". Ну и ещё музыка. Костюмы - это чаще всего описание анимации персонажей. Но, как обычно, есть несколько исключений: палитра для спрайтов и тайлов, описание фона локаций, таблица перекодировки символов, список предлогов и ещё один неиспользуемый.
Нормальные же комнаты описываются несколькими параметрами: размеры, список прямоугольников, по которым можно перемещаться, локальные объекты, карта фоновых тайлов, скрипты, привязанные к комнате.
Вообще мне не очень нравилось таскать вместе с исполняемым файлом распакованный Maniac Mansion. К тому же, это ещё и накладывает ограничения на код, нельзя насовсем избавиться от системного ПЗУ, отображённого в память. Поэтому я даже начал писать скрипты для перепаковки всех ресурсов внутрь самой игры. Но это дело так и не завершил, поэтому почти всё так и читается с диска.
Для работы с диском используются вызовы esxDOS. Они очень похожи на те, что поддерживаются POSIX-системами: open, close, read, write, seek. С помощью этих вызовов я просто открываю файлы с SD-карты и читаю оттуда нужные данные. Каждая комната хранится в файле с названием, соответствующим номеру, поэтому находить их легко.
Память теневая и физическая
Памяти у ZXNext вагон - целых 768 килобайт в базовой версии (остальные 256 кб зарезервированы под ПЗУ). Но одновременно можно обращаться только к 64 килобайтам - таково ограничение восьмибитного Z80. Чтобы работать с большим объёмом, в адресное пространство процессора подключаются разные физические страницы памяти. Размер страницы у ZXNext - 8 килобайт.
Так как памяти много, я стал слегка ей разбрасываться, чтобы упростить переключение страниц. Например, физические страницы с 32 по 47 отведены загружаемым скриптам. Scumm может параллельно выполнять несколько скриптов, и для каждого есть свой код и свой набор переменных. Хотя до 8 килобайт этот набор не добирает, я всё равно отдаю каждому загруженному скрипту одну страницу.
Когда нужно поработать со скриптом, соответствующая страница отображается по адресу 0x4000 функцией переключения. Поэтому остальные функции интерпретатора скриптов просто работают, будто бы он единственный.
ПЗУ ZX Spectrum, которое по умолчанию занимает нижние 16 килобайт адресного пространства, мне тоже не особенно нужно. Вместо ПЗУ по этим адресам отображается тайловая память при распаковке графических ресурсов NES. Но потом ПЗУ подключается назад, потому что без него не будет работать дисковая подсистема, а из файлов я читаю ресурсы игры.
Я компилировал свой проект с помощью z88dk. Это Си-комплятор и набор библиотечных функций. Там есть поддержка всяких фич ZXNext: системные регистры, банки памяти, дисковая подсистема. Но вся стандартная библиотека была мне не нужна. По сути, я использовал только функции чтения с диска и процедуры для умножения и деления целых чисел. А из того, что не занимает памяти, всякие полезные макросы и константы для работы со встроенной периферией.
Поэтому я форкнул этот SDK и добавил в него свою библиотеку, где будут только нужные мне подсистемы. В итоге 64Кб адресного пространства используются так:
0x0000 - 0x3FFF: ПЗУ и временное подключение ОЗУ для распаковки тайлов.
0x4000 - 0x5FFF: Сюда подключаются всякие страницы с данными. Для скриптов, для графики, для игровых объектов.
0x6a00 - ~0xCC00: Исполняемый код. 627 байт на библиотечные функции и ~24Кб код эмулятора.
Дальше секции DATA и BSS. То есть переменные инициализированные чем-то полезным и нулями.
В самом верху живёт стек. Пока писался код и стандартная библиотека была большой, бывало такое, что стек налезал на другие полезные данные.
Эмулятор и отладка
Чтобы запускать то, что получилось, и не дёргать постоянно реальный компьютер, я использовал эмулятор ZEsarUX. Сейчас есть ещё пара эмуляторов (правда только под Windows), поддерживающих спрайты и всё остальное от ZXNext, а когда я начинал разработку, это было не везде. Даже в ZEsarUX не было спрайтов с относительными координатами, но они очень удачно появились как раз тогда, когда я стал пытаться их использовать.
Несмотря на то, что ZX Spectrum у меня был примерно с 91 по 97 годы, никаких крупных программ я под него не писал. А теперь мы все избалованы отладчиками, так что разработка под такой ограниченный компьютер вызвала некоторые сложности. Отладчику в эмуляторе далеко до GDB, поэтому я отлаживал в основном с помощью печати сообщений на консоль. Конечно же, печатал я не на экране виртуального компьютера, а вне его. Для этого используется "секретный" порт, поддерживаемый эмулятором. Правда пришлось его немного подпилить, чтобы поддерживались символы перевода строки. Автор эмулятора почему-то их заменял на '?'.
Один из багов не удавалось найти с помощью отладочного вывода, потому что он возникал только на реальном железе. Дело было в том, что я просто пользовался какими хотел страницами памяти, но не все из них инициализировал явно или неявно. На настоящем компьютере перед моей программой успевала выполниться прошивка и главное меню, поэтому в памяти был мусор вместо нулей, которые я ожидал.
Но несколько багов пока ещё остались. Иногда проблема в том, что отладочный вывод их чинит. В таких случаях приходится отлаживать программу методом пристального взгляда.
Музыка
У каждого управляемого персонажа в инвентаре есть CD-плеер. Все они играют разные мелодии, которые можно выключать, если надоест. Но насколько реально проиграть музыку от NES на звуковых чипах ZX Spectrum Next? Может надо брать её из другой версии? Ведь звуковые контроллеры совсем разные и программируются тоже по-разному. С этим я поразбираться ещё не успел.
Итог
У меня получился более-менее работающий эмулятор с поддержкой Scumm второй версии и ресурсов NES. Буду ли я чинить оставшиеся баги и добавлять поддержку других игр, пока не знаю.