Прошлым летом меня пригласили на тусовку в Саннивейле. Оказалось, что у хозяев в гараже есть аркадный автомат NBA JAM Tournament Edition на четверых игроков. Несмотря на то, что игре уже больше 25 лет (она была выпущена в 1993 году), в неё по-прежнему очень интересно играть, особенно для увлечённых любителей.
Меня удивил список игроков Chicago Bulls, в котором не было Майкла Джордана. Согласно источникам, [1], Эм-Джей получил собственную лицензию и не был частью сделки, которую Midway заключила с NBA.
Расспросив владельца автомата, я узнал, что хакеры выпустили мод игры для SNES «NBA Jam 2K17», позволяющий играть новыми игроками и Эм-Джеем, но никто не занимался разбором того, как работала аркадная версия. Поэтому мне обязательно нужно было заглянуть внутрь.
Предыстория
История NBA Jam начинается не с баскетбола, а с Жан-Клод Ван Дамма. Примерно то же время, когда был выпущен «Универсальный солдат», «Midway Games» разработала технологию, позволяющую манипулировать большими оцифрованными фотореалистичными спрайтами, сохраняющими сходство с настоящими актёрами. Это был огромный технологический прорыв: анимации с 60 кадрами в секунду, невиданные ранее спрайты размером 100x100 пикселей, каждый из которых имел собственную 256-цветную палитру.
Компания с большим успехом использовала эту технологию в популярном шутере «Terminator 2: Judgment Day»[2], но не смогла приобрести лицензию на «Универсального солдата» (финансовые условия JCVD оказались для Midway неприемлемыми [3]). Когда переговоры закончились неудачей, Midway сменила курс и начала разработку боевой игры в духе мегахита Capcom 1991 года под названием «Street Fighter II: The World Warrior».
Была собрана команда из четырёх человек (Эд Бун писал код, Джон Тобиас занимался артом и сценарием, Джон Вогель рисовал графику, а Дэн Форден был звукорежиссёром). Спустя год упорного труда[4] Midway выпустила в 1992 году Mortal Kombat.
Визуальный стиль сильно отличался от привычного пиксель-арта, а дизайн игры оказался, мягко говоря, «спорным». Игра с литрами крови на экране и безумно жестокими добиваниями-«фаталити» мгновенно стала мировым хитом и за год заработала почти 1 миллиард долларов[5].
SF2: 384×224 с 4 096 цветами.
MK: 400×254 с 32 768 цветами.
Интересный факт: как и в VGA Mode 0x13 на PC, в этих играх пиксели были не квадратными. Хотя буфер кадров Mortal Kombat имеет размер 400 × 254, он растягивается до соотношения 4:3 ЭЛТ-экрана, обеспечивая разрешение 400 × 300[6]
Оборудование Midway T-Unit
Разработанное компанией Midway для Mortal Kombat «железо» оказалось очень хорошим. Настолько хорошим, что ему дали собственное название T-Unit и повторно использовали в других играх.
- Mortal Kombat.
- Mortal Kombat II.
- NBA Jam.
- NBA Jam Tournament Edition.
- Judge Dredd (не была выпущена).
T-Unit состоит из двух плат. Бо́льшая из них занимается игровой логикой и графикой.
Плата процессора NBA JAM TE Edition (примерно 40х40 см, или 15 дюймов).
Другая плата менее сложна, но тоже способна на многое. Она предназначена для аудио, но способна воспроизводить не только музыку при помощи FM-синтеза, но и цифровой звук.
Звуковая плата соединена с источником питания и графической платой, установленной сзади. Обратите внимание на огромный радиатор, расположенный в верхнем левом углу.
Вместе эти две платы содержат более двух сотен чипов, резисторов и EPROM. Разбираться во всём этом только на основании серийных номеров было бы очень трудоёмко. Но, как ни удивительно, иногда у устройств родом из 90-х случайно обнаруживается документация. А в случае NBA Jam она оказалась просто отличной.
Архитектура Midway T-Unit
В поисках данных я наткнулся на NBA Jam Kit. Уровень детализации этого документа потрясает[7]. Среди прочего, мне удалось найти подробное описание монтажных соединений, в том числе EPROM-ов и чипов.
Информация из документа позволила нарисовать схему плат и определить функцию каждой части. Для помощи в поиске компонентов плата имеет координаты с началом в правом нижнем углу (UA0), увеличивающиеся до левого верхнего угла (UJ26).
Сердцем основной платы служит Texas Instrument TMS34010 (UB21) с частотой 50 МГц и с 1 мебибайтом кода в EPROM-ах и 512 кибибайтами DRAM[8]. 34010 — это 32-битный чип с 16-битной шиной, имеющий такие замечательные графические инструкции, как PIXT and PIXBLT[9]. В начале 90-х этот чип использовался в нескольких картах аппаратного ускорения [10], и я думал, что он обрабатывает солидный объём графических эффектов. Как ни удивительно, но он занимается только игровой логикой, и ничего не отрисовывает.
На самом деле графическим монстром оказался чип U13 под названием «DMA2». Согласно схемам из документации, он обладает внушительными (по тем временам) 32-битной шиной данных и 32-битной адресной шиной, из-за чего стал самым большим чипом на плате. Эта специализированная интегральная схема (ASIC) способна на множество графических операций, о которых я расскажу ниже.
Все чипы (System RAM, GFX EPROM, Palette SDRAM, Code, Video Banks) отображены в одно 32-битное адресное пространство и подключены к одной шине. Мне не удалось разыскать никакой информации о протоколе шины, поэтому если вам что-то о нём известно, пишите на электронную почту.
Обратите на хитрый трюк: один компонент EPROM (отмечен синим) используется для создания другой системы хранения (и экономии денег). Эти EPROM на 512 кибибайта имеют 32-битные адресные выводы и 8-битные выводы данных. Для 34010, которому требуется 16-битная шина данных, два EPROM (J12 и G12) подключены с двукратным чередованием адресов, создавая память в 1 мебибайт. Аналогичным образом графические ресурсы подключены с четырёхкратным чередованием адресов для образования 32-битного адреса с 32-битной системой хранения данных, содержащей 8 мебибайт.
Хотя в этой статье я в основном буду рассматривать графический конвейер, не могу противиться искушению, а потому вкратце расскажу про аудиосистему.
Схеме звуковой карты показан Motorola 6809 (U4 с частотой 2 МГц), на который подаются инструкции из одного EPROM (U3) для управления музыкой и звуковыми эффектами.
Чип FM-синтеза Yamaha 2151 (3,5 МГц) генерирует музыку непосредственно из инструкций, полученных от 6809 (музыка использует довольно малую полосу пропускания).
OKI6295 (1 МГц) отвечает за воспроизведение цифрового аудио в формате ADPCM (например, легендарной «Boomshakalaka»[11] Тима Китцроу).
Заметьте, что на основной плате те же синие 512-кибибайтные EPROM 32a/8d используются в 16-битной системе с двукратным чередованием адресов для хранения оцифрованных голосов, а для 8-битных инструкций данных/адресов Motorola 6809 чередования нет.
Жизнь кадра
Весь экран NBA Jam индексирован в 16-битной палитре. Цвета хранятся в формате xRGB 1555 в палитре размером 64 кибибайт. Палитра разделена на 128 блоков (256 * 16 бит) по 512 байт. Спрайты, хранящиеся в EPROM, помечены как «GFX». Каждый спрайт имеет собственную палитру размером до 256x16-битных цветов. Спрайт часто использует целый блок палитры, но никогда не больше одного. ЭЛТ-сигнал передаётся на монитор при помощи RAMDAC, который для каждого пикселя считывает индекс из банков Video DRAM и выполняет поиск цвета в палитре.
Жизнь каждого кадра видео NBA Jam протекает следующим образом:
- Игровая логика состоит из потока 16-битных инструкций, передаваемых из J12/G12 в 34010.
- 34010 считывает ввод игроков, вычисляет состояние игры, а затем отрисовывает экран.
- Для отрисовки на экране 34010 сначала находит неиспользуемый блок в палитре и записывает туда палитру спрайта (палитры спрайтов хранятся вместе с инструкциями 34010 в J12/G12).
- 34010 выполняет запрос к DMA2, в который включаются адрес и размеры спрайта, используемый 8-битный блок палитры, усечение, масштабирование, способ обработки прозрачных пикселей, и так далее.
- DMA2 считывает 8-битные индексы спрайтов из GFX ROM чипа J14-G23, комбинирует это значение с индексом 8-битного блока палитры и записывает 16-битный индекс в видеобанки. DRAM2 можно считать блиттером, считывающим 8-битные значения из GFX EPROM и записывающим 16-битные значения в видеобанки
- Шаги 3-5 повторяются, пока не будут выполнены все запросы на отрисовку спрайтов.
- Когда наступает момент обновления экрана, RAMDAC преобразует находящиеся в видеобанках данные в сигнал, который может понять ЭЛТ-монитор. Чтобы полосы пропускания хватило на преобразование 16-битного индекса в 16-битный RGB, палитра хранится в чрезвычайно дорогой и чрезвычайно быстрой SRAM.
Интересный факт: флеш-прошивка EPROM — это не такой уж простой процесс. Перед записью в чип необходимо полностью стереть всё его содержимое.
Для этого чип необходимо облучить УФ-освещением. Для начала нужно отклеить стикер с верхней части EPROM, чтобы открыть его схему. Затем EPROM помещается в особое устройство-стиратель, в котором есть УФ-лампа.
Спустя 20 минут EPROM будет заполнен нулями и готов к записи.
Документация MAME
Разобравшись с оборудованием, я понял, в какой набор EPROM можно было записaть Майкла Джордана (палитра хранится в Code EPROM-ах, а индексы — в GFX EPROM-ах). Однако я по-прежнему не знал ни точного местоположения, ни используемого формата.
Недостающая документация нашлась в MAME.
На случай, если вы не знаете, как работает этот потрясающий эмулятор, вкратце объясню. MAME построена на основе концепции «драйверов», являющихся имитацией платы. Каждый драйвер составлен из компонентов, имитирующих (обычно) каждый чип. В случае Midway T-Unit нас интересуют следующие файлы:
mame/includes/midtunit.h mame/src/mame/video/midtunit.cpp mame/src/mame/drivers/midtunit.cpp mame/src/mame/machine/midtunit.cpp cpu/tms34010/tms34010.h
Если взглянуть на drivers/midtunit.cpp, то мы увидим, что каждый чип памяти является частью единого 32-битного адресного пространства. Из исходного кода драйвера видно, что палитра начинается с адреса 0x01800000, gfxrom — с адреса 0x02000000, а чип DMA2 — с 0x01a80000. Чтобы проследовать по пути данных, нам нужно проследить за функциями C++, выполняемыми, когда объектом операции считывания или записи является адрес памяти.
void midtunit_state::main_map(address_map &map) {
map.unmap_value_high();
map(0x00000000, 0x003fffff).rw(m_video, FUNC(midtunit_vram_r), FUNC(midtunit_vram_w));
map(0x01000000, 0x013fffff).ram();
map(0x01400000, 0x0141ffff).rw(FUNC(midtunit_cmos_r), FUNC(midtunit_cmos_w)).share("nvram");
map(0x01480000, 0x014fffff).w(FUNC(midtunit_cmos_enable_w));
map(0x01600000, 0x0160000f).portr("IN0");
map(0x01600010, 0x0160001f).portr("IN1");
map(0x01600020, 0x0160002f).portr("IN2");
map(0x01600030, 0x0160003f).portr("DSW");
map(0x01800000, 0x0187ffff).ram().w(m_palette, FUNC(write16)).share("palette");
map(0x01a80000, 0x01a800ff).rw(m_video, FUNC(midtunit_dma_r), FUNC(midtunit_dma_w));
map(0x01b00000, 0x01b0001f).w(m_video, FUNC(midtunit_control_w));
map(0x01d00000, 0x01d0001f).r(FUNC(midtunit_sound_state_r));
map(0x01d01020, 0x01d0103f).rw(FUNC(midtunit_sound_r), FUNC(midtunit_sound_w));
map(0x01d81060, 0x01d8107f).w("watchdog", FUNC(watchdog_timer_device::reset16_w));
map(0x01f00000, 0x01f0001f).w(m_video, FUNC(midtunit_control_w));
map(0x02000000, 0x07ffffff).r(m_video, FUNC(midtunit_gfxrom_r)).share("gfxrom");
map(0x1f800000, 0x1fffffff).rom().region("maincpu", 0); /* mirror used by MK*/
map(0xff800000, 0xffffffff).rom().region("maincpu", 0);
}
В конце того же файла «drivers/midtunit.cpp» мы видим, как содержимое EPROM-ов загружается в ОЗУ. В случае графических ресурсов «gfxrom» (сопоставленных с адресом 0x02000000), мы можем увидеть, что они растянулись на 8 мебибайта адресного пространства в блоках чипов с четырёхкратным чередованием адресов. Заметьте, что имена файлов соответствуют расположению чипов (например, UJ12/UG12). Набор этих файлов EPROM в мире эмуляторов более известен под названием «ROM».
ROM_START( nbajamte )
ROM_REGION( 0x50000, "adpcm:cpu", 0 ) /* sound CPU*/
ROM_LOAD( "l1_nba_jam_tournament_u3_sound_rom.u3", 0x010000, 0x20000, NO_DUMP)
ROM_RELOAD( 0x030000, 0x20000 )
ROM_REGION( 0x100000, "adpcm:oki", 0 ) /* ADPCM*/
ROM_LOAD( "l1_nba_jam_tournament_u12_sound_rom.u12", 0x000000, 0x80000, NO_DUMP)
ROM_LOAD( "l1_nba_jam_tournament_u13_sound_rom.u13", 0x080000, 0x80000, NO_DUMP)
ROM_REGION16_LE( 0x100000, "maincpu", 0 ) /* 34010 code*/
ROM_LOAD16_BYTE( "l4_nba_jam_tournament_game_rom_uj12.uj12", 0x00000, 0x80000, NO_DUMP)
ROM_LOAD16_BYTE( "l4_nba_jam_tournament_game_rom_ug12.ug12", 0x00001, 0x80000, NO_DUMP)
ROM_REGION( 0xc00000, "gfxrom", 0 )
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug14.ug14", 0x000000, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj14.uj14", 0x000001, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug19.ug19", 0x000002, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj19.uj19", 0x000003, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug16.ug16", 0x200000, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj16.uj16", 0x200001, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug20.ug20", 0x200002, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj20.uj20", 0x200003, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug17.ug17", 0x400000, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj17.uj17", 0x400001, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug22.ug22", 0x400002, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj22.uj22", 0x400003, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug18.ug18", 0x600000, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj18.uj18", 0x600001, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_ug23.ug23", 0x600002, 0x80000, NO_DUMP)
ROM_LOAD32_BYTE( "l1_nba_jam_tournament_game_rom_uj23.uj23", 0x600003, 0x80000, NO_DUMP)
ROM_END
Интересный факт: в показанном выше примере кода последний параметр функции был заменён на «NO_DUMP», чтобы можно было загружать модифицированные EPROM. Эти поля обычно[12] являются хешем CRC/SHA1 содержимого EPROM. Именно так MAME определяет, какой игре принадлежит ROM и позволяет узнать, что один из ROM-ов в наборе отсутствует или повреждён.
Сердце видеодвижка: DMA2
Ключом к пониманию формата графики является функция, обрабатывающая запись/чтение DMA в 256 регистров DMA2, расположенные по адресам с 0x01a80000 до 0x01a800ff. Весь тяжкий труд по обратной разработке уже был выполнен разработчиками MAME. Они даже уделили время превосходному документированию формата команд.
Регистры DMA ------------------ Регистр | Бит | Применение ----------+-FEDCBA9876543210-+------------ 0 | xxxxxxxx-------- | пиксели, отбрасываемые в начале каждой строки | --------xxxxxxxx | пиксели, отбрасываемые в конце каждой строки 1 | x--------------- | включение записи (или очистки, если ноль) | -421------------ | bpp изображения (0=8) | ----84---------- | размер пропуска после = (1<<x) | ------21-------- | размер пропуска до = (1<<x) | --------8------- | включение пропуска до/после | ---------4------ | включение усечения | ----------2----- | отзеркаливание по y | -----------1---- | отзеркаливание по x | ------------8--- | передача ненулевых пикселей как цвета | -------------4-- | передача нулевых пикселей как цвета | --------------2- | передача ненулевых пикселей | ---------------1 | передача нулевых пикселей 2 | xxxxxxxxxxxxxxxx | младшее слово адреса исходника 3 | xxxxxxxxxxxxxxxx | старшее слово адреса исходника 4 | -------xxxxxxxxx | x получателя 5 | -------xxxxxxxxx | y получателя 6 | ------xxxxxxxxxx | столбцы изображения 7 | ------xxxxxxxxxx | строки изображения 8 | xxxxxxxxxxxxxxxx | палитра 9 | xxxxxxxxxxxxxxxx | цвет 10 | ---xxxxxxxxxxxxx | масштаб по x 11 | ---xxxxxxxxxxxxx | масштаб по y 12 | -------xxxxxxxxx | усечение сверху/слева 13 | -------xxxxxxxxx | усечение снизу/справа 14 | ---------------- | тест 15 | xxxxxxxx-------- | байт обнаружения нуля | --------8------- | дополнительная страница | ---------4------ | размер получателя | ----------2----- | выбор верха/низа или левого/правого края для регистра 12/13
Существует даже функция отладки, позволяющая сохранять исходные спрайты в процессе передачи их DMA2 (функция написана давним участником проекта MAME Райаном Холтцом[13]). Мне достаточно было просто сыграть в игру, чтобы все файлы с метаданными сохранились на диск.
Оказалось, что спрайты составлены из простых элементов 16-битной палитры без сжатия. Однако не у всех спрайтов количество цветов одинаково. Некоторые спрайты используют только 16 цветов с 4-битными индексами цветов, а другие — 256 цветов и требуют 8-битных индексов цветов.
Патчинг
Теперь я знаю расположение и формат спрайтов, поэтому осталось выполнить минимальный объём реверс-инжиниринга. Я написал на Golang небольшую программу для устранения чередования EPROM-ов «code» и «gfx». Устранив чередование, легко выполнять поиск ASCII или известных значений, потому что я работал ровно с тем, как выглядит ОЗУ во время выполнения программы.
После этого легко можно найти характеристики игрока. Оказалось, что все они хранились один за другим в 16-битном беззнаковом формате big-endian (что очень логично, ведь 34010 работает с big-endian). Я добавил патчер для модификации атрибутов игроков. Не особо разбираясь в баскетболе, я ввёл SPEED=9, 3 PTS=9, DUNKS=9, PASS=9, POWER=9, STEAL=9, BLOCK=9 и CLTCH=9.
Также я написал код для патчинга игры новыми спрайтами с единственным ограничением — новые спрайты должны иметь те же размеры, что и заменяемые. Для фотографии Эм-Джея я создал 256-цветный индексированный PNG (его можно посмотреть здесь).
Наконец, я добавил код для преобразования промежуточного формата в формат с чередованием для записи в отдельные файлы EPROM-ов.
Запускаем игру
После патчинга содержимого EPROM инструмент диагностики NBAJam показал, что содержимое некоторых чипов помечено как «BAD». Я этого ожидал, потому что пропатчил только содержимое EPROM-ов, но не озаботился поиском формата CRC и даже местом их хранения.
GFX EPROM-ы помечены красным (UG16/UJ16, UG17/UJ17, UG18/UJ18, UG20/UJ20, UG22/UJ22 и UG23/UJ23), потому что в них хранятся изменённые мной изображения. Два EPROM-а, в которых хранятся инструкции (UG12 и UJ12) тоже красные, потому что там находятся палитры.
К счастью, здесь CRC не используются для защиты от модифицированного контента и нужны только для проверки целостности чипов. Игра запустилась. И заработала!
Hasta La Vista, Baby!
Закончив с техническими трудностями, я быстро потерял интерес к инструменту и прекратил его разработку. Идеи для тех, кто захочет поиграться с кодом:
- Добавьте в Восточную конференцию Toronto Raptors.
- Добавьте возможность изменения имён игроков. К сожалению, они состоят не из ASCII, а являются заранее сгенерированными изображениями.
Книга про NBA Jam
Если вы фанат NBA Jam, то Рейан Али написал о ней целую книгу[14]. Купить её можно здесь.
Исходный код
Если вы хотите внести свой вклад или просто посмотреть, как всё устроено, то полный исходный выложен на github здесь.
Ссылки
[1] Источник: 'NJA Jam' by Reyan Ali
[2] Источник: 'NJA Jam' by Reyan Ali
[3] Источник: 'NJA Jam' by Reyan Ali
[4] Источник: Mortal Kombat 1 Behind The Scenes
[5] Источник: 'NJA Jam' by Reyan Ali
[6] Источник: 4:3 versus Square Pixels
[7] Комментарий: к сожалению, эпоха такой великолепной документации давно прошла
[8] Источник: Mame NBA Jam start-up screen
[9] Источник: TMS34010 Instruction Set
[10] Источник: T34010 User Guide
[11] Источник: NBA Jam—BoomShakaLaka video
[12] Источник: MAME T-Unit driver.cpp
[13] Источник: Commit 'midtunit.cpp: Added an optional DMA-blitter viewer'
[14] Источник: 'NBA JAM Book' by Reyan Ali
Автор: PatientZero