В 1990-м Titus выпустила Fire & Forget II: гонку, где спорткар с пулемётом умеет взлетать. Я гонял её ребёнком с одной дискеты, а недавно полез внутрь её бинарника, просто посмотреть, как она устроена. Внутри оказалось неожиданно много: кооперативная многозадачность на голом 8086, собственный скриптовый язык с французскими мнемониками, две виртуальные машины, исполняющие один и тот же байткод, и полминуты игры самого разработчика, спрятанные в демо-записи. Их не видел никто и никогда. По дороге игра пересобралась на C11 и теперь живёт в браузере: не трогайте клавиатуру в меню, и увидите то самое демо. Но статья не про порт:
Под катом много пиксель-арта, игра «найди отличия», где каждое найденное отличие оказывается уроком об устройстве оригинала, и подробный разбор того, как один враг рождается в перетягивании каната между двумя виртуальными машинами.
–
Кадр против кадра
Оригинал запускается в QEMU и честно думает, что на дворе 1990 год и он пишет в видеопамять EGA. Отладчик замораживает его на каждом кадре и снимает содержимое этой видеопамяти в файл. Тот же кадр, из тех же самых нажатий, рисует мой порт. Дальше два кадра вычитаются попиксельно, и каждый несовпавший пиксель означает ровно одно: в оригинале есть код, который я ещё не прочитал.
Из этих стоп-кадров и родилась статья. Два из них ниже, давайте сыграем. Слева (или сверху) оригинал, рядом моя версия тех недель, когда она ещё врала. Отличия попробуйте найти сами, ответы под спойлерами.
Кадр первый: смерть босса.
<spoiler title=«Отличия (их три группы) и что они рассказывают»>
Розовым подсвечено всё, что не сошлось.
-
Надпись STAGE 1 COMPLETED. Она набрана не шрифтом, а большими спрайтами: в банке графики лежат цифры и алфавит по 16 пикселей на символ, и движок выкладывает из них строку, пока поднят флаг «уровень пройден». Мой порт тех времён об этой ветке ещё не знал.
-
Сердцевина взрыва. Босс умирает не одним взрывом, а пятью: он превращается во взрыв сам и высаживает четыре свои копии со смещениями. Все пять лежат на одной глубине, и порядок наложения решает правило вставки в список отрисовки: элемент с равным ключом встаёт перед старыми, поэтому раньше поставленный кусок пламени рисуется позже и оказывается сверху. Порт складывал пламя в обратном порядке.
-
Пыль под колёсами. Отдельный маленький спрайт, который движок подставляет при разгоне, торможении и жёстком повороте. Машина как раз тормозит после победы.
Кадр второй: будничный момент первого уровня.
<spoiler title=«Отличий ровно пять»>
-
Четыре канистры на дороге. Их высаживает скриптовая машина уровня, о ней следующий раздел.
-
Камень слева на обочине.
-
Куст справа. Камни и кусты выпускает отдельное кольцо придорожного декора, а их спрайты игра печатает сама, при старте, из других спрайтов.
-
Четыре синих шарика в правом блоке приборки: это жизни. Их выкладывает автомат панели, по одному глифу за кадр.
-
Столбик шкалы 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. Первые три ступени строят микропрограммы прореживания вида «взять четыре пикселя, пропустить один». Дальше дешевле: каждая следующая ступень это половина одной из предыдущих, честное «выкинуть каждый второй пиксель».
Лестница решает задачу перспективы. Дорога уходит в глубину, врагов на ней много, масштабировать спрайты каждый кадр 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, таблица рекордов, меню, и демо запускается с начала.
А плёнка продолжается:
Граница проходит посреди 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/