Я разобрал игру своего детства: многозадачность из 1990-го, потерянный язык уровней, виртуальные машины и тайм-капсула

от автора

Fire & Forget II: логотип Titus, злодей из заставки, гонка и таблица рекордов

Fire & Forget II: логотип Titus, злодей из заставки, гонка и таблица рекордов

В 1990-м Titus выпустила Fire & Forget II: гонку, где спорткар с пулемётом умеет взлетать. Я гонял её ребёнком с одной дискеты, а недавно полез внутрь её бинарника, просто посмотреть, как она устроена. Внутри оказалось неожиданно много: кооперативная многозадачность на голом 8086, собственный скриптовый язык с французскими мнемониками, две виртуальные машины, исполняющие один и тот же байткод, и полминуты игры самого разработчика, спрятанные в демо-записи. Их не видел никто и никогда. По дороге игра пересобралась на C11 и теперь живёт в браузере: не трогайте клавиатуру в меню, и увидите то самое демо. Но статья не про порт:

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

Кадр против кадра

Оригинал запускается в QEMU и честно думает, что на дворе 1990 год и он пишет в видеопамять EGA. Отладчик замораживает его на каждом кадре и снимает содержимое этой видеопамяти в файл. Тот же кадр, из тех же самых нажатий, рисует мой порт. Дальше два кадра вычитаются попиксельно, и каждый несовпавший пиксель означает ровно одно: в оригинале есть код, который я ещё не прочитал.

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

Кадр первый: смерть босса.

Смерть босса: слева оригинал, справа порт

Смерть босса: слева оригинал, справа порт

<spoiler title=«Отличия (их три группы) и что они рассказывают»>

Дифф кадра со смертью босса: розовым подсвечены расхождения

Дифф кадра со смертью босса: розовым подсвечены расхождения

Розовым подсвечено всё, что не сошлось.

  1. Надпись STAGE 1 COMPLETED. Она набрана не шрифтом, а большими спрайтами: в банке графики лежат цифры и алфавит по 16 пикселей на символ, и движок выкладывает из них строку, пока поднят флаг «уровень пройден». Мой порт тех времён об этой ветке ещё не знал.

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

  3. Пыль под колёсами. Отдельный маленький спрайт, который движок подставляет при разгоне, торможении и жёстком повороте. Машина как раз тормозит после победы.

Кадр второй: будничный момент первого уровня.

Будничный кадр первого уровня: сверху порт, снизу оригинал

Будничный кадр первого уровня: сверху порт, снизу оригинал

<spoiler title=«Отличий ровно пять»>

Дифф будничного кадра: пять пропавших подсистем

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

  2. Камень слева на обочине.

  3. Куст справа. Камни и кусты выпускает отдельное кольцо придорожного декора, а их спрайты игра печатает сама, при старте, из других спрайтов.

  4. Четыре синих шарика в правом блоке приборки: это жизни. Их выкладывает автомат панели, по одному глифу за кадр.

  5. Столбик шкалы F короче на ступеньку: шкалы тоже растут по одной ступеньке за кадр, прямо в персистентную видеопамять, которую никто никогда не перерисовывает целиком.

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

Язык, на котором написаны уровни

В данных игры, между палитрами и таблицами, лежит колонка из четырнадцати слов по восемь символов: CLEAR, GETCHAR, PUT, WHILE, GOTO, SETDIST, GOSUB, RETURN, BEQ, WAVE, а следом DEFB, END, INCLUDE и ERREUR. Последнее слово французское, и это не случайность: игру писали во Франции, на Atari ST, со своими внутренними инструментами. Перед нами таблица ключевых слов ассемблера, которым дизайнер собирал уровни. Сам инструмент не сохранился: ни в поставке, ни где-либо в сети его следов нет. А байткод, который он когда-то собрал, есть: 3090 байт, шесть скриптов.

Чтобы прочитать их, я написал этому языку дизассемблер, а потом и ассемблер: если текст собирается обратно байт в байт (а он собирается, все 3090), значит, семантика восстановлена верно. Вот как начинается первый уровень:

script0:        SETDIST 10000        ; дистанция до лидера конвояloc_003:        GOSUB loc_671        ; подпрограмма «заправка»: пять канистр        CLEAR        GETCHAR JUMPER       ; ждать, пока спрайт джампера будет напечатан        GETCHAR T54        PUT  recs_ada_jumper ; высадить волну джамперов        GOSUB loc_671        CLEAR        GETCHAR RIDER        PUT  recs_975_rider  ; три волны байкеров подряд        PUT  recs_98a_rider        PUT  recs_975_rider

Читается почти как план тренировки. SETDIST задаёт, сколько ехать до босса. PUT высаживает волну и ждёт, пока её выбьют или она уедет за горизонт. WHILE в конце скрипта гоняет уровень по кругу, пока дистанция не выйдет. GOSUB зовёт подпрограммы: loc_671 это библиотечная «заправка», её вызывают все уровни. Дизайнер писал уровень как программу, с библиотекой общих кусков.

PUT заодно переключает сегмент кривизны трассы: повороты дороги в этой игре синхронизированы с волнами врагов, потому что и тем и другим управляет один скрипт. А GETCHAR означает «подожди, пока спрайт этого врага будет напечатан». Готовых спрайтов врагов в игре нет, их печатают прямо по ходу заезда, и об этой печати следующий раздел.

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

        ; (dead) CLEAR        ; (dead) GETCHAR EXT_LIFE        ; (dead) PUT  recs_810_ext_life   ; волна с дополнительной жизнью        ; (dead) RETURN

Подпрограмма, которую никто не вызывает, и блоки записей врагов, на которые никто не ссылается. Ассемблер Titus не выбрасывал мёртвый код, а честно линковал его в образ. Так в данных 1990 года остались лежать волны, которые дизайнер написал и отключил.

Рождение джампера

Джампер это шагающий мех, который прыгает на игрока. Целого джампера в игре нет. В банке спрайтов хранятся шесть ЛЕВЫХ половинок: корпус и кабина для двух поз шага плюс длинные ноги для прыжка. Мех симметричен, и художник не стал тратить память на правую сторону:

Шесть левых половинок джампера в банке спрайтов

Шесть левых половинок джампера в банке спрайтов

Собирает его короткая программа. Вот она целиком, как лежит в данных игры:

SH_NEW(33, 64)       ; новый холст 64×33SH_SYM(144, 0, 9)    ; корпус: половинка + её зеркало, через ORSH_SYM(146, 16, 0)   ; кабина: половинка + зеркалоSH_NEW2(32, 64)      ; второй холст: вторая поза шагаSH_SYM(145, 0, 10)SH_SYM(147, 16, 0)SH_MIPS              ; напечатать лестницу уменьшенных копий

Четыре зеркальных наложения, и половинки становятся полными позами. Тем же способом, отдельным блоком той же программы, собирается поза прыжка. Причём кабину она переиспользует: свои у неё только ноги.

Три собранные позы джампера: два шага и прыжок

Три собранные позы джампера: два шага и прыжок

Дальше в дело вступает SH_MIPS: печать лестницы уменьшенных копий, от 64×33 до 16×4. Первые три ступени строят микропрограммы прореживания вида «взять четыре пикселя, пропустить один». Дальше дешевле: каждая следующая ступень это половина одной из предыдущих, честное «выкинуть каждый второй пиксель».

Лестница уменьшенных копий джампера: от 64×33 до 16×4

Лестница уменьшенных копий джампера: от 64×33 до 16×4

Лестница решает задачу перспективы. Дорога уходит в глубину, врагов на ней много, масштабировать спрайты каждый кадр 8086-му не по карману. Проектор считает экранный размер врага из его глубины и выбирает готовую ступень: далёкий джампер это 16×4, близкий 64×33, в кадре не масштабируется ни один пиксель. Прыжок устроен так же дёшево: ИИ прибавляет 10 к базовому номеру спрайта, и вся лестница подменяется прыжковым банком.

Печатью занимается вторая виртуальная машина. Она читает тот же байткод уровня, что и первая, своим собственным счётчиком команд, и идёт на одну волну впереди. GETCHAR, на котором исполнитель будет ждать, для строителя означает противоположное: начать печать.

Печать размазана по кадрам. Кадр длится 55 миллисекунд, в нём уже сидят дорога, враги и музыка, поэтому строитель укладывает за кадр фиксированный объём пиксель-строк: для холста шириной 64 это две-три строки. Полный джампер с лестницей собирается десятки кадров и поспевает точно к своей волне.

кадр   исполнитель уровня               строитель спрайтов   1   бой с байкерами                  джампер: строки 1..2 из 33   2   бой с байкерами                  джампер: строки 3..4 ...   ...                              ...  45   байкеры выбиты, CLEAR            джампер готов: маркер «плюс»  46   GETCHAR: маркер «плюс», дальше   прошёл свой CLEAR, сменил  47   PUT: джамперы выезжают             половину банка спрайтов ...   бой с джамперами                 печатает следующую волну  90   GETCHAR: маркер ещё «минус»      не успел допечатать  91   ждёт: на обочину сыплются камни  бюджет поднят в полтора раза  95   маркер «плюс»: волна выезжает    идёт дальше, до своего CLEAR

Разбежаться машинам не дают два тормоза. Строителя держит CLEAR, граница волны: здесь он ждёт, пока исполнитель не начнёт волну, которую строитель уже обеспечил спрайтами. Дальше одной волны вперёд он не уходит.

К тому же CLEAR приварен двойной буфер. Банк собранных спрайтов разбит на две половины, и на каждой границе строитель переключается на другую: новая волна печатается там, откуда текущая не рисует.

Исполнителя держит знак маркера: отрицательный, пока идёт печать, положительный, когда готово. Упёршись в «минус», он не простаивает даром: движок поднимает бюджет печати в полтора раза и сыплет на обочину камни и кусты, чтобы глазу было чем заняться.

Многозадачность 1990 года выглядит так: две виртуальные машины на одном байткоде, притормаживающие друг друга, и делёж процессора бюджетом «по чуть-чуть за кадр». Экран загрузки в этой игре замаскирован под пейзаж.

Тайм-капсула на 434 байта

Attract-демо в этой игре не видеоролик и не скрипт поведения. Это запись НАЖАТИЙ, которую движок проигрывает через тот же обработчик ввода, что и живую клавиатуру. Лежит она прямо в данных: 217 пар байт вида [счётчик, маска].

маска:    битовая карта удерживаемых кнопок          0x01 огонь    0x10 газ      0x04 / 0x08 руль          0x02 START    0x20 тормоз   0x40 ракетный аккордсчётчик:  сколько ещё кадров держать эту маску

Пара покрывает счётчик+1 кадров, и 434 байта разворачиваются в 2667 кадров ввода: почти две с половиной минуты игры при 18.2 кадра в секунду. Простейший RLE, потому что в 1990-м каждый байт был на счету.

Демо-заезд длится ровно 2041 кадр, одну минуту 52 секунды: на этом кадре погибает босс первого уровня. У живого игрока дальше идёт экран «YOU GOT ME, BUT I AM NOT DEAD» и второй уровень.

В режиме демо движок при выходе из гонки принудительно гасит флаг «уровень пройден» и обнуляет счёт. Игра сворачивает на ветку смерти: GAME OVER, таблица рекордов, меню, и демо запускается с начала.

А плёнка продолжается:

Демо-плёнка целиком: синим уровень 1, оранжевым скрытый уровень 2

Демо-плёнка целиком: синим уровень 1, оранжевым скрытый уровень 2

Граница проходит посреди 28-й строки. Синяя пара {17,0×00} это последние 18 кадров первого уровня: руки отпущены, босс взрывается. Оранжевая { 0,0×09} сразу за ней: огонь и руль, начался второй уровень.

Дальше в ленте ещё 626 кадров, 34 секунды ввода: газ, манёвры между минами, и даже маска 0x43, ракетный аккорд из двух кнопок. Разработчик записывал заезд с запасом, сквозь смерть босса и дальше, а attract-режим всегда режет плёнку в одном и том же месте. Эти 34 секунды не воспроизводились никогда: демо физически не может туда доехать.

Я скормил полную ленту своему движку в режиме обычной игры, без демо-флага, и оригиналу под QEMU тем же способом. Второй уровень сошёлся бит в бит. Плёнка 1990 года оказалась одновременно тестом для кода и тайм-капсулой: полминуты того, как в свою игру играл её создатель.

Капсулу можно открыть вживую: titustribute.com/ff2/demo2. Страница перематывает первый уровень, доигрывает скрытые 34 секунды и передаёт управление вам, ровно в том месте, где разработчик отпустил клавиши.

Поиграть и потрогать

Порт живёт в браузере: Titustribute, работает и на телефоне, с виртуальным джойстиком. Исходники движка и все восстановленные данные, включая levels.wave и дизассемблер языка уровней, лежат на GitHub: n0isy/fire-and-forget-2-recomp. Оригинальной графики в репозитории нет: чтобы играть, нужны файлы вашей собственной копии игры, скрипт извлечёт их сам.

ссылка на оригинал статьи https://habr.com/ru/articles/1057756/