Образовательный контент — редкая рубрика в моем бложике. Я в основном просто высказываю мнение. Оно может быть подкреплено какими-то фактами, а может быть проявлением моей фантазии. В конце концов, видеоигры — это вкусовщина. Даже лучшие учебники по их разработке скорее всего научат вас как хорошему, так и плохому. Но полезный образовательный контент, касающийся видеоигр вполне возможен и в этом посте мне хотелось бы раскрыть тему эмуляторов. Они повсюду и вы ими пользуетесь даже когда об этом не подозреваете. Но что такое эмуляторы, с чем сопряжена их разработка и какова их ценность в современном мире? Тема очень большая, поэтому можно сразу оговориться, что исчерпывающей информации о принципах работы эмуляторов здесь не будет. Но думаю, что я вполне в состоянии объяснить на пальцах как работают эмуляторы видеоигровых консолей, даже если мне ни разу не доводилось писать таковой. Эмуляторы сложны, но я в общих чертах понимаю как работают компьютеры. А значит я понимаю что должен эмулировать эмулятор. И попробую передать частичку этого знания вам.

С чем имеем дело

Давайте начнем с простейших вещей. У меня тут видеоигровой блог, поэтому начнем с видеоигры. Любая игра представляет из себя последовательность инструкций, которые мы даем компьютеру или видеоигровой консоли. Они поступают поочередно — нам нужно отрисовать меню, дать игроку начать игру и реагировать на его действия. Чтобы мы могли сделать любую игру, которая может прийти нам в голову, набор инструкций, которые принимает компьютер должен быть исчерпывающим и универсальным. Например, инструкция «проведи врага к игроку так, чтобы он обошел препятствия» слишком специфична. Что, если у нас нет врагов вообще? Что, если нет препятствий? А вести надо по двумерному или трехмерному пространству? А что если игроков несколько? А что если игрок уже погиб? Как вообще реализовать такую инструкцию в железе? Наверное можно, но не факт, что нужно.

Но проблему решить точно нужно, ведь игру мы сделать хотим. Не вдаваясь в детали, кажется мы можем воспользоваться алгоритмом поиска кратчайшего пути. Тогда можно будет по этому пути провести врага (как это анимировать в реальном времени мы как-нибудь потом разберемся). Это математика плюс алгоритм. То есть это просто последовательность математических операций, комбинированных с «если, то», которые выполнят ровно ту сложную инструкцию, которая была приведена в пример выше. А это в свою очередь выглядит очень простым, нам нужно:

  • Складывать числа (мы считаем расстояние все-таки)
  • Запоминать их (мы считаем сразу несколько маршрутов)
  • Сравнивать числа (мы хотим выбрать короткий маршрут)
  • Переходить к следующей операции в зависимости от результата сравнения

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

Современному разработчику всех этих инструкций обычно не видно, он работает с высокоуровневыми абстракциями. Кроме того, зачастую не нужно даже знать как работают все эти ваши алгоритмы, потому что можно просто подключить библиотеку, которая умеет это делать. Агуша-программирование, так сказать. Но в конечном итоге, когда все это попадет в лапки цифрового мозга, оно будет превращено в набор низкоуровневых инструкций, понятных конкретному железу.

О! Получается, для разного железа нужны разные инструкции? Абсолютно верно. В этом и заключается суть эмуляции на очень базовом уровне. Посудите сами. У вас есть картридж для NES и вы хотите запустить Super Mario Bros на вашем Windows ПК (например). Для начала, как Windows вообще поймет, что вы ему суете? Ну вот сняли вы каким-то образом дамп с картриджа, а дальше что? Это же просто файл с байтиками. Windows должна каким-то образом понять где там первая инструкция. Для этого данные для обычных исполняемых файлов уложены специфическим для windows образом. Ну и даже если мы как-то Windows покажем где именно находится первая инструкция, внутри вашего компьютера скорее всего стоит x86-64 процессор, который просто не сообразит что вы от него хотите, когда эта самая первая инструкция будет передана ему на выполнение. Он не знает этот набор инструкций, оно не для него было написано. В лучшем случае программа просто сразу упадет. В худшем — начнет что-то делать! Поэтому подобную чужеродную программу лучше на выполнение не передавать. Мало ли чего оно там навыполняет.

Значит нам нужно решить обе эти проблемы. Средства, предоставляемые операционной системой для выполнения программ под эту ОС, нам придется создать самостоятельно. Инструкции для процессора нам тоже нужно как-то преобразовать. И вот теперь у нас достаточно информации, чтобы понять суть решаемой проблемы и перейти от вводной части к мякотке.

Мякотка

Представим себя в абстрактной, но довольно приземленной ситуации. Допустим, у нас есть пошаговый рецепт, написанный на каком-нибудь древнем и забытом языке. Мы переводчики. Но готовить блюдо надо вот уже прямо сейчас, а готовить мы не умеем, вообще. Поэтому мы переводим по одной фразе и реагируем на внешние раздражители, когда нас спрашивают «а сколько солить?», «а корочка твердая должна быть?», «на какой огонь ставить?» и другие вопросы, которые помогут повару сотворить сей кулинарный шедевр. Мы могли бы и сами все приготовить конечно, но не умеем.

Это то, что нам нужно запрограммировать! Инструкции для Ricoh (процессора NES) не были сочинены внеземным разумом, а значит там скорее всего все довольно просто — будут стандартные сложение, умножение, условные переходы и так далее. Так что мы просто берем каждую инструкцию, преобразовываем ее во что-то понятное для нашего железа и все! Дело сделано! Мы великолепны!

Но есть нюанс. В NES операции происходили прямо на железе. Допустим, мы хотим сложить 6 и 7 — мы кладем их в две особенные ячейки в памяти процессора и вызываем инструкцию, которая перезаписывает в ячейку результат сложения. Доступ к ячейкам (они называются регистрами) молниеносен!

В нашем случае 6 и 7 записываются в переменные в памяти, а не в регистры процессора. Так-то они конечно дойдут до регистров, но гарантировать, что они оттуда не выдавятся сложно. Если мы оперируем высокоуровневым языком, прямого контроля железа у нас нет. Так что 6 и 7 лягут в обычную память пока мы будем читать следующие инструкции из файла с дампом. И если вот так неряшливо относиться к написанию нашего эмулятора, вся работа будет занимать на порядок, а то и несколько больше времени, чем эта же работа занимала у железа из восьмидесятых. Ну, предположим, в NES был не очень быстрый процессор, а мы сегодня офигеть какие быстрые, но это же не масштабируется! PS1 на своих 34 мегагерцах задушит почти любой современный процессор, если мы будем работать с такой черепашьей скоростью.

Так что от агуша-программирования придется отказываться. Если в деле разобраться, можно много чего держать рядышком с процессором, чтобы он мог это подобрать, когда ему понадобится. Более того, можно даже хитрым образом все хранить прямо в регистрах, совсем как на NES. Потребуются неплохие такие навыки программирования и понимание железа, но ничего невозможного в этом нет. Добиться приемлемой скорости с небольшим замедлением — можно!

Кроме того, мы вообще-то не находимся в той ситуации, когда нам нужно прямо сейчас готовить по рецепту на древнем языке. Можно перевести все заранее и спокойно приготовить. Тогда вроде бы и думать обо всяких хитрых оптимизациях не понадобится!

YouTube Video

Вон как много всего приготовили же!

К сожалению тут аналогия с рецептом полностью ломается. Но ломается она очень причудливым образом. Представьте, что рецепт — это одновременно и инструкция по приготовлению какого-то блюда и его фотография. А еще рецепт зациклен на самого себя и вынуждает вас переходить к шагам, которые вы уже выполнили повторно в зависимости от текущих обстоятельств. Проблема в том, что старые консоли не особенно отличались высокой производительностью и стандартизированными аппаратными возможностями. На какие только ухищрения не шли поставщики видеоигровых консолей, чтобы обеспечить максимальную производительность за минимальную стоимость! В этом был весь смысл, ведь консоль должна была быть очень дешевой. За ту цену, по которой продавались китайские клоны NES, никакой полноценный компьютер собрать не получилось бы даже по самым оптовым расценкам.

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

Короткий список ужасов для тех, кто немного понимает программирование:

  • На ROM-чипах картриджа код и данные перемешаны, достоверно отделить одно от другого нельзя. Более того, код и данные могут лежать в одних и тех же байтах и интерпретироваться в реальном времени в зависимости от того, каким образом вы на эти байты посмотрите
  • Один и тот же адрес памяти может содержать разные данные. Некоторые картриджи переключали подсет памяти в ROM. Консоли был доступен фиксированный набор адресов, находящийся в определенном подсете, которые можно было переключать динамически. В один момент вы достаете с адреса одно, в другой — другое
  • JMP инструкции могут прыгать не в первый байт инструкции, поэтому каждый байт инструкции может быть началом инструкции. Ну и в целом до начала рантайма трудно понять какие будут адреса для условных переходов, их нужно пересчитывать динамически и это плохо сочетается с принципом выполнения любых современных программ. Ненулевая вероятность, что для того чтобы заставить условные переходы работать правильно, вам придется писать свою операционную систему чисто для выполнения рекомпилированного исходного кода (или выполнять кучу кода на уровне ядра)
  • Некоторые программы модифицируют свой собственный код в реальном времени, но это скорее исключение. Но такое бывает

Другими словами, делать рекомпиляцию — точно не массовое решение, которое позволит нам играть во всю библиотеку NES здесь и сейчас. Это решение имеет право на жизнь, но к каждой игре должен быть свой подход. Нужно возвращаться к решению на основе интерпретации в реальном времени, которое было описано выше. И вроде получилось у нас там все, да?

Нет!

Вероятно вы знали, что в вашем компьютере много аппаратной начинки, которая работает асинхронно, то есть независимо друг от друга. Проще говоря, у вас есть процессор и видеокарта. Иногда видеокарта интегрирована в процессор, но это сути дела сильно не меняет и к нашей дальнейшей теме отношения не имеет. Потому что в NES CPU и PPU (Picture Processing Unit) были разделены.

Разделены они были довольно простым образом. На каждые три такта PPU, CPU выполняет один свой такт. То есть пока мы два числа складываем на CPU (занимает 2-6 тактов), PPU рисует 6-18 пикселей. И CPU надо вовремя передать PPU информацию, чтобы на экран не вывелось невнятное месиво из кривых прямугольников, потому что CPU забыл обновить таблицу спрайтов.

И звучит все это довольно просто. На каждый такт эмуляции CPU просто делаем три эмуляции тактов PPU. Но это очень наивное представление об асинхронности. Три така PPU делаются одновременно с тем, как CPU делает один свой. Это значит, что CPU может подложить PPU данные в то время, когда он их уже использует. А это делать небезопасно! Более того, такты PPU могут иметь разное выравнивание относительно CPU, они не всегда идут в четкой синхронизации друг с другом.

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

Если с эмуляцией сорокалетней консоли у нас такие сложности, каким же чудом у нас сегодня есть эмуляторы PS2? Это чудо называется высокоуровневой эмуляцией и является одним из тех самых компромиссов, которых мы хотели избежать. На подобных эмуляторах нельзя запустить любую игру и играть в нее так, как она игралась бы на реальном железе. Более того, точная эмуляция консолей пятого поколения, которым на сегодня уже по тридцать лет, на современных компьютерах невозможна. Но об этом я наверное напишу как-нибудь в другой раз, эта статья уже и без того большая.

YouTube Video

У Димитриса Яннакиса вообще много материалов по эмуляторам на канале, рекомендую

Выводы

Сегодня эмуляторы очень популярны. Вы можете играть в старые игры на мобильных телефонах, китайских портативках и даже на своих собственных консолях — многие переиздания являются ничем иным как эмуляторной оболочкой, которая запускает старую игру на новом железе. Но немногие подозревают о том, на какие ухищрения пришлось пойти, чтобы оно вообще заработало, не говоря о том, чтобы эмуляция проходила в реальном времени с хорошей частотой кадров и правильно синхронизировала звук с картинкой. Точная и бескомпромиссная программная эмуляция сегодня возможна только для очень старого железа. Когда вы играете в проекты с PS1 — все начинает разваливаться стоит вам только запустить какую-нибудь из менее известных игр. Поэтому сохранение доступа к реальному железу так важно. Только там вы можете сыграть в игру так, как это подразумевалось разработчиками. Хотя у эмуляции тоже есть свои преимущества, но о них я расскажу в другой раз.

Самая важная же деталь, которая касается эмуляции и, в особенности, точной эмуляции — это отсутствие реальной документации на железо. Все описанные выше особенности NES и других консолей приходится открывать с помощью реверс-инжиниринга. Нужно буквально тестировать железо консоли на все возможные внешние раздражители. Сегодня этому очень помогает близость архитектур современных консолей и распространение LLM. Но мастерство и непреклонность эмуляторописателей трудно переоценить. Так же как трудно переоценить правовые риски, на которые они идут в современном мире. Сохранение видеоигровой истории — это сложная и опасная работа, заслуживающая уважения.

P.S. Если вам хочется получше разобраться в железной стороне вопроса и дойти до уровня транзисторов и логических вентилей, горячо рекомендую почитать книжку Чарльза Петцольда. Он хорошо и популярно объясняет принцип работы примерно всего, что лежит в основе современных компьютеров

P.P.S. Я старалась, но если все равно получилось непонятно и сложно — пишите в комментарии. Я подумаю как еще можно упростить материал

Обсуждение в telegram