[статья] Эмулятор, в чём соль?

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

Преамбула

Согласно умным книжкам, эмуляция — это поведение одной компьютерной системы (хост), имитирующее поведение другой компьютерной системы (гость). Результатом является возможность выполнения программ для гостя на хосте (или использование хостом периферийных устройств, предназначенных для гостя) вопреки различиям между ними.

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

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

Что же представляет собой это поведение? Как именно компьютер обрабатывает информацию? Да и что вообще такое эта информация?

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

«Информация есть запомненный выбор одного варианта из нескольких возможных и равноправных», — говорит синергетика. Минимальная единица количества информации определяется минимальным числом вариантов выбора — их может быть минимум два; и то, какой выбор из двух вариантов следует сделать, может быть описано одним битом. С битами работает двоичная система счисления, и (что отрадно) в биты может быть перекодирована любая информация, любое число. Компьютеру остается лишь совершить с этими битами нужные действия и выдать соответствующий результат.

Так как бит — вещь виртуальная, мы можем физически представлять его абсолютно в любом виде, лишь бы была возможность его распознать. Арифметически, бит обозначает варианты 0 и 1, логически — true и false (истина и ложь). В вычислительной технике и сетях передачи данных значения 0 и 1 обычно передаются различными уровнями напряжения или тока. Например, в микросхемах на основе транзисторно-транзисторной логики значение 0 представляется напряжением в диапазоне от +0 до +0.8 В, а значение 1 — напряжением в диапазоне от +2.4 до +5.0 В.

Эмулируемое

Прежде, чем данные обрабатывать, надо их как-то пересылать, а также хранить. Для начала, расскажем, как биты представлены физически на запоминающих устройствах.

Картриджи Нинтендо использовали масочные ПЗУ, то есть при производстве ненужные перемычки в них не пропаивались — они пропускались при помощи специальных масок, соединяя транзисторы (или диоды) с каналами ввода-вывода только там, где надо заказчику. Есть несколько вариантов реализации битов, для простоты представьте, что по каждому адресу памяти расположено 8 транзисторов (по разрядности шины данных, о ней ниже), от каждого из которых идет по два контакта — один на землю, другой на шину данных. Если перемычка на месте, ток идет на шину данных, если нет, в землю. Шина данных имеет 8 проводов, по которым передается ток от каждого из транзисторов. Таким образом, если перемычки сохранены у первого, второго и последнего транзисторов, от них будет идти сигнал, соответствующий единице, от остальных  ноль, и в итоге мы получим такой вид битов для данного примера: 1100 0001. При необходимости, машина сложит их в байт. Какое число сохранить таким образом по какому адресу — решает автор игры.

Устройство ячеек оперативной памяти значительно сложнее. Каждый бит состоит из двух инверторов, двух битовых линий (BL), являющихся инверсиями друг друга, и линии машинного слова (WL), открывающей доступ битовых линий к ячейке. Линия слова соединяет набор из 8 (для Денди) таких ячеек-битов с шиной адреса, а битовые линии идут на шину данных. Для записи на одну битовую линию шлют сигнал, соответствующий единице, на другую — нулю. Они поступают на входы инвертирующих элементов и замыкают/размыкают соответствующие пары транзисторов в них (M5 и M1, M6 и M3). С выходов этих элементов сигнал идет обратно на входы их антиподов, повторяя цикл. То есть поступивший в ячейку сигнал бесконечно инвертируется по кругу, сохраняясь и после закрытия каналов доступа к ячейке, так как у инверторов есть свое питание (V). Для чтения по обеим битовым линиям шлют сигнал средний между 1 и 0, потом подается 1 на линию слова, и напряжение одной битовой линии падает, а другой — поднимется. По тому, на какой из битовых линий заряд больше, определяют, какое значение было в ячейке.

Системная шина — это совокупность проводников между всеми устройствами системы, обеспечивающая их сообщение. Многие компоненты приставки, к которым обращается процессор, распределены по оперативной памяти в виде последовательных регионов, обращение к каждому из них происходит посредством отправки сигнала по шине адреса до целевой ячейки памяти, и она шлет на шину данных нужную информацию, которую процессор потом получает для обработки. Шина представляет собой набор проводов, каждый из которых пересылает один бит информации. Количество этих проводов определяет разрядность (битность) шины. Например, адресная шина NES имеет 16 контактов, и способна пересылать 2 байта информации, обеспечивая доступ к 0xFFFF (65535) адресам (даже если некоторые из них не существуют физически). Шина же данных у NES 8-разрядная, то есть передаваться может только один байт данных. Максимальный объем пересылаемой одновременно информации называется машинным словом, и в случае NES он равен одному байту. Слово процессоров SNES и MegaDrive состоит из 16 бит, то есть двух байт. Именно это имеют в виду, когда говорят о битности консоли (вспоминаем массивы из транзисторов, находящиеся по каждому адресу).

Ну и наконец, вкратце о процессоре. Процессор, а в нашем случае, микропроцессор, — это микроэлектронное устройство, которое выполняет машинные инструкции, написанные программистом. Он состоит из управляющего автомата, арифметико-логического устройства и регистров. Регистры — это ячейки памяти, хранящие значения, необходимые процессору для обработки, например, временные результаты преобразований, адрес следующей инструкции, специальные флаги. Арифметико-логическое устройство — это набор элементов, обеспечивающих выполнение арифметических операций (сложение, вычитание, отрицание, увеличение и уменьшение на один), а также битовых (И, НЕ, ИЛИ и сдвиги). Управляющий автомат просто управляет всем этим.

Инструкции, которые процессор выполняет своими микроэлементами, читаются из памяти (в нашем случае, из ПЗУ) по очереди, в цикле, в соответствии с архитектурой фон Неймана:

  1. Процессор выставляет число, хранящееся в регистре счётчика команд, на шину адреса и отдаёт памяти команду чтения.
  2. Выставленное число является для памяти адресом; память, получив адрес и команду чтения, выставляет содержимое, хранящееся по этому адресу, на шину данных и сообщает о готовности.
  3. Процессор получает число с шины данных, интерпретирует его как команду (машинную инструкцию) из своей системы команд и исполняет её.
  4. Если последняя команда не является командой перехода, процессор увеличивает на единицу (в предположении, что длина каждой команды равна единице) число, хранящееся в счётчике команд; в результате там образуется адрес следующей команды.

Размер команды (опкода) в битах является еще одним способом определения длины машинного слова для данной системы, а значит и ее битности. Опкоды представляют собой числа, в которые были перекодированы понятные человеку мнемоники (краткие названия команд), которые и использовались при написании программы. В таком цифровом виде они представляют машинный код. Для первых поколений игровых консолей (и некоторых компьютеров), программы писались сразу на языке, с которым работает процессор, то есть ассемблере, тогда как позже народ стал писать на языках высокого уровня типа Си, и потом компилировать Си-код под конкретный вид ассемблера (многие игры для MegaDrive тоже писались так). Каждая команда занимает в процессоре определенное число тактов, а количество тактов, которые он способен выполнить в секунду, называется тактовой частотой. Скорость выполнения каждой инструкции фиксирована, как и сама тактовая частота.

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

Эмулирующее

Фундаментально эмулятор делится на две части, которые некоторые при разработке таки смешивают в кучу, но профи давно научились разделять: ядро и клиент.

 

Ядро — это собственно эмулятор консоли, полная имитация всех ее действий, нужных игрокам. Ядра отличаются точностью эмуляции, открытостью исходного кода, портируемостью. Точность выясняется на основе проходимости эмулятором специальных тестовых ROM’ов, разработанных с целью проверки реализации в эмуляторе особых приемов, которые позволяла консоль, и которые использовались в играх (или демках, луркаем «Демо-сцена»). Открытость исходников определяется лицензией, под которой написано и распространяется ядро. Существуют, например, такие варианты: платная; бесплатная с закрытым кодом; бесплатная с открытым кодом, не разрешенным к изменению; бесплатная с открытым кодом, разрешающая изменения в определенных пределах; бесплатная с открытым кодом, разрешающая любые изменения и использование кода в любых других проектах. Портируемость определяется возможностью скомпилировать ядро в обычный DLL файл и использовать в таких мультиплатформенных эмуляторах как RetroArch или BizHawk, которые являются клиентами. Mednafen, напротив, распространяется со встроенными ядрами, хотя они и могут при желании быть скомпилированы в DLL и использованы вне его. Ну и есть эмуляторы, в которых ядро и клиент по хардкоду (не путать с хардкором) слиты воедино, и использовать их в таких мультиядерных клиентах не представляется возможным без существенных модификаций (FCEUX, DeSmuMe и большинство остальных).

Ядро генерирует видео, звук, другие виды вывода, если консоль это позволяет (дрожание геймпада, светомузыка, песни и пляски с цыганами и медведями), для генерации их она использует пользовательский ввод. Для того чтобы пользователь отправил ядру свой ввод, а на выходе получил требуемые развлечения, ядру нужна прослойка, которая бы сообщала его с компьютером. Этой прослойкой является клиент. Клиенты различаются целевой платформой, драйверами, пользовательским интерфейсом. Целевая платформа — это операционная система, под которую написан клиент, например Windows, Linux, MacOS, Android. Драйвера — это специальные средства работы с графикой, звуком, вводом/выводом, которые также заточены под определенную операционную систему (хотя могут быть и кроссплатформенные), и, собственно, обеспечивают нашу связь с ядром. Пользовательский интерфейс, в первую очередь, делится на интерфейс командной строки и графический, а уж в каждом из этих видов клепают кто во что горазд.

Заглянем, наконец, в ядро!

Оно должно работать с такой скоростью, чтобы визуально было неотличимо от работы консоли. Это простое требование таит в себе много головной боли для авторов эмуляторов.

Самое, пожалуй, важное понятие, касающееся этой скорости — кадр. Как и в кинематографе, изображение, показываемое на экране, меняется (обновляется) с определенной частотой. Эта частота для подавляющего большинства старых консолей настраивалась равной частоте обновления экрана телевизора: для Америки и Японии 60 кадров в секунду (NTSC), для Европы и части Азии — 50 (PAL и SECAM).

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

Теперь к рутине. Довольно часто производители консолей стремились использовать топовые для своего времени процессоры, чтобы умыть конкурентов и дать по-настоящему уникальный экспириенс игроку, хотя, конечно, со скидкой на гарантированную возможность их серийного штампования (Дримкасту не повезло с этим, и он стал фатальной консолью для Сеги) и на юзабельность для игроделов (Атари Ягуар провалилась как раз из-за невозможности использовать пресловутые 64 бита без лютых танцев с бубном: Игорь в итоге потонул). Тот факт, что процессоры были не абы какие, означает, что воспроизвести их работу программными методами сможет далеко не всякий комп. И ядра, всерьез заточенные на точность (соответствия изначальным параметрам консоли, соблюдения даже мелких нюансов) могут и на современных компьютерах тормозить, если эмулируемая консоль не достаточно далека от нас по времени. Так, качественная эмуляция PSX или NDS на старых машинах 60 кадров в секунду уже не выдаст, а качественная эмуляция N64 может загрузить и аппарат посовременнее. И синхронная эмуляция нескольких процессоров — это только полбеды: есть еще трехмерная графика, которую современный юзер любит подвергнуть улучшениям и заставить эмулятор рендерить картинку не 320 на 240 пикселей, как делало оригинальное железо (отсюда такая пикселявость раннего 3D), а на весь экран, да еще и с дополнительным хитрым сглаживанием.

Так или иначе, частоту эмулируемой системы придется подгонять под частоту работы компьютера. Для этого ядру требуется какое-то понятие о времени. А так как процессор работает на фиксированной (и известной) частоте, то от этой частоты нам и надо отталкиваться как от фундаментальной. Так и делают сами процессоры: каждая инструкция в них выполняется определенное количество тактов. Процессорный такт — это минимальный юнит его работы. Длительность его определяется частотой, на которую настроен тактовый генератор процессора, именно он служит точным и надежным таймером для всей системы. Генератор этот выдает волну, и все механизмы процессора синхронизируют по ней свою работу. Частота обычно выставляется равной одной операции, совершаемой процессором. Вот каковы эти операции: фетч (захавывание) инструкции, декодирование инструкции, выполнение ее (основа и цель всего происходящего, собственно вычисления), доступ к памяти и  предоставление результата операции. В таком случае, можно говорить о средней частоте циклов за операцию. Если же частота выставляется меньшей, чем скорость выполнения одной операции, используют выражение «частота операций за цикл».

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

В итоге можно, наконец, представить себе то, что делает эмулятор каждый кадр:

while (!stop_emulation)       // пока эмуляция не остановлена,
{
executeCPU(cycles);     // выполнять инструкции в течение
// определенного числа тактов.
generateInterrupts();   // выяснить, следует ли запустить
// какое-то прерывание.
emulateGraphics();      // сгенерировать кадр графики.
emulateSound();         // сгенерировать сэмплы звука.
emulateOtherStuff();    // сэмулировать кузькину мать.
timeSynchronization();  // синхронизировать все вышеназванное.
}

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

Следует заметить, что хотя число тактов за кадр может быть известно и фиксировано, оно не дает гарантии абсолютной точности, так как бывают задержки в очереди выполняемых инструкций (pipeline stall), промахи кэша (cache miss) и другие пакости. Эмулятор может основываться на счетчике тактов, и неточность итоговой скорости будет незаметна невооруженным глазом, но сами консоли привязывают свои тайминги к скорости движения луча по кинескопу. Причем в консолях, работающих с тайлами (плиточной графикой), генерация картинки происходит постепенно, пиксель за пикселем, сканлайн за сканлайном (так называется одна строка изображения), пока центральный процессор выполняет свои вычисления, а в более поздних, работающих с растром, была уже область памяти (фреймбуффер), содержащая всю картинку целиком и позволяющая подвергать ее нужным эффектам, и только сам вывод ее на экран там осуществлялся все так же постепенно. Момент перехода луча в кинескопе от одного сканлайна к другому называется HBlank (horizontal blanking interval), а момент его перехода от последнего пикселя последнего сканлайна к первому пикселю нового сканлайна — VBlank (vertical blanking interval).

Рассмотрим работу основных компонентов ядра.

Центральный процессор

Основной цикл действий, выполняемых процессором, мы уже видели: прочитать память по адресу, указанному в счетчике инструкций (program counter), расшифровать содержимое в пригодную для выполнения машиной форму, произвести нужные вычисления и записать результат в регистры и/или память.

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

Чтение, расшифровка и выполнение написанного программистом осуществляется в гигантских количествах (тактовая частота процессора NES — 1,79 МГц, а процессора MegaDrive — 7,67 МГц, то есть, имеем дело с миллионами команд в секунду),  поэтому желающему научить свою программу это делать обычно требуется найти самый быстрый способ. Вот основные методы:

Интерпретатор

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

if      (a) { stuff       }
else if (b) { other stuff }

здесь не применима, так как проверять отдельно каждое условие чрезвычайно медленно. Народ обычно использует оператор switch, который позволяет вместо проверки каждого условия по отдельности просто совершать мгновенные прыжки к нужной функции. Причем функция в данном случае — это не обязательно конкретная функция в программе, а просто некий порядок действий. Вот как будет выглядеть использование этого оператора с прыжками на основе машинного кода инструкции:

switch (machine_code)
{
case 0:
// операции для опкода 0
break;
case 1:
// операции для опкода 1
break;
...

case N:
// операции для опкода N
break;
default:
// если пришедшее значение не соответствует
// ни одному из известных опкодов
// даем юзеру понять, что это нелегальная инструкция
break;
}

Можно и по-другому. Заготавливаем собственно функции на каждый опкод, а указатели на адреса этих функций в памяти программы помещаем в большой массив (таблицу). Тогда доступ к ним можно будет осуществлять по порядковому номеру, которым тоже будет машинный код:

void *opcode[256];      // таблица указателей на функции
// 256 - число опкодов в примере
opcode[0] = func0;      // адрес функции опкода 0
opcode[1] = func1;      // адрес функции опкода 1
...
opcode[N] = funcN;      // адрес функции опкода N

Вызов этих функций будет выглядеть так:

opcode[machine_code]();

Есть еще один способ. И большинство программистов его с младенчества ненавидят, хотя визуально он будет мало отличаться от метода через switch. Это метод через goto. То есть вместо switch/case перед каждым набором операций ставим лейбл и все так же заставляем программу прыгать по этим лейблам.

Если скорости в итоге все равно не хватает, можно все это написать… тоже на ассемблере! Правда хороший компилятор языка высокого уровня (Си сотоварищи) вашу таблицу прыжков тоже скомпилирует в ассемблерный код, и получится примерно то же самое. Но для настоящего гика этого, конечно, недостаточно, и он будет сам писать на языке целевого процессора, просто потому что может (SWAG).

Рекомпилятор (двоичный транслятор)

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

  • бывает код, который модифицирует сам себя на лету (тогда его транслированная версия не будет актуальной),
  • невозможно однозначно определить, где в игре код, а где данные, не нуждающиеся в трансляции (для этого надо ее запустить),
  • транслировать обращения игры к параллельно работающим компонентам крайне сложно (особенно, если нам надо их синхронизировать).

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

Примеров не будет, так как это уже совсем дикие дебри. Лучше перейдем к тому, что собой представляют функции, имитирующие поведение процессора.

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

Вот как выглядит контекст центрального процессора эмулятора FCEUX:

typedef struct __X6502 {           // контекст представлен структурой:
int32  tcount;                // временный счетчик циклов
uint16 PC;                    // счетчик инструкций
uint8  A, X, Y, S, P, mooPI;  // прочие регистры
uint8  jammed;                // остановлен ли CPU
int32  count;                 // основной счетчик циклов
uint32 IRQlow;                // контакт сигнала прерывания
uint8  DB;                    // кэш шины данных
int    preexec;               // неиспользованная переменная :D
#ifdef FCEUDEF_DEBUGGER             // указатели на функции отладчика
void  (*CPUHook)  (struct __X6502 *);
uint8 (*ReadHook) (struct __X6502 *, unsigned int);
void  (*WriteHook)(struct __X6502 *, unsigned int, uint8);
#endif
} X6502;

А так выглядит функция одного из опкодов в Genesis+GX, ядре MegaDrive в составе RetroArch (размер регистров там равен 4 байтам):

// копирование содержимого одного регистра в другой
static void m68k_op_move_32_d_d(void)
{
// получить из регистров информацию об источнике (resource)
uint res = DY;
// получить из регистров информацию об адресате (destination)
uint* r_dst = &DX;
// скопировать исходное число в нужный регистр
*r_dst = res;
// установить флаги регистра состояния
FLAG_N = NFLAG_32(res);
FLAG_Z = res;
FLAG_V = VFLAG_CLEAR;
FLAG_C = CFLAG_CLEAR;
}

Все, что здесь обозначено заглавным шрифтом, представляет собой макросы препроцессора, то есть функции, созданные при помощи директивы #define. Она позволяет нужные команды вставлять прямо в код (избегая необходимости каждый раз вызывать функцию, что стоит лишнего времени), используя при этом короткие имена, отделяя тело макроса от его имени пробелом. Там, где надо максимально разогнать код, макросы используются предельно часто. Вот как определены DX и DY из выдержки выше:

#define DX (REG_D[(REG_IR >> 9) & 7])
#define DY (REG_D[REG_IR & 7])

REG_D и REG_IR — это тоже макросы, дающие доступ к элементам контекста процессора, >> и & — битовые операции, повторяющие нюансы его работы. Таким образом, можно все что угодно выстроить в последовательность макросов и обозначить коротким именем, и она при компиляции выстроится в полноценный код. Читать и отлаживать его будет невыносимо, но зато все будет летать.

Память

Обычно абсолютно все компоненты системы, к которым процессор может осуществлять доступ, имеют свои адреса в памяти. Описание того, какие адреса какому устройству соответствуют, называется картой распределения памяти (memory map). Есть случаи, когда устройства ввода/вывода мапятся не в память, а на особые порты, но консоли это редко используют, и обычно все доступно по единой карте адресов.

Вся память делится на регионы, соответствующие своим девайсам: ПЗУ, ОЗУ, порты ввода-вывода (их так называют, даже если они находятся в памяти), видеопамять, регистры звука и так далее. Однако регионы редко покрывают всю физически доступную память. Так, адресное пространство N64 имеет протяженность в 4 ГБ, хотя реально занято лишь около 4 МБ. То есть между регионами существуют либо «дыры» — открытая шина (unmapped memory), либо зеркала. На рисунке ниже показана память MegaDrive, адреса памяти на письме обычно обозначаются шестнадцатиричными числами (префикс $ или 0x).

В случае попытки доступа к открытой шине, если система не использует никакую защиту от дурака, возвращаться будет число, которое было последним на шине данных. Если же мы имеем дело с зеркалами, то будем получать число, которое есть по этому же оффсету (смещение от начального адреса) в другом регионе, зеркалом которого является данная область памяти. Это происходит не потому, что ячейки этих зеркал в системе реально есть и не используются, а потому что при доступе к памяти происходит неполное декодирование адреса. Например, если у нас есть 256 байт адресного пространства (8 бит, $00-$FF), а денег хватило только на 127 реально существующих ячеек (7 бит, $00-$7F), то старший бит (восьмой) мы можем при доступе к памяти вообще игнорировать: запросит игра адрес $05 или $85, нам важно не будет. Так как $80 — это и есть восьмой бит, который мы не рассматриваем, в итоге игра в обоих случаях получит одно и то же значение, хранящееся в одной и той же ячейке.

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

// чтение из оперативной памяти
unsigned int readRAM(unsigned int address)
{
// проверка принадлежности
if (address >= 0 && address < RAMsize)
{
// возвратить значение из нужной ячейки
return RAM[address];
}
else
{
// напечатать ошибку
print("Нет такого адреса в RAM!!!")
// возвратить фигу
return null;
}
}

Можно еще на лету распознавать, к какому региону обращаться, но, как мы помним, все эти if/else очень долгие, когда речь заходит о миллионах операций в секунду. Для ускорения работы следует выкинуть все возможные проверки, не пожертвовав при этом стабильностью, конечно. Вот что делает FCEUX:

  1. Создает тип переменной «адрес функции» для записи и для чтения.

typedef uint8 (*readfunc)(uint32 A);
typedef void (*writefunc)(uint32 A, uint8 V);

  1. Создает макрос для объявления тел этим функциям:

#define DECLFR(x) uint8 x(uint32 A)
#define DECLFW(x) void  x(uint32 A, uint8 V)

  1. Объявляет тела для каждого региона, вот тело функции чтения из RAM:

static DECLFR(ARAM) {
return RAM[A];
}

  1. Заготавливает столько функций, сколько у нас адресов в регионе — и так для каждого региона! Причем отдельно для записи, отдельно для чтения.

// объявление "заготовщика"
void SetReadHandler(int32 start, int32 end, readfunc func) {
// все нужные проверки
...
// перебрать все ячейки региона памяти
for (x = end; x >= start; x--)
// создать фанкцию для каждой из них
ARead[x] = func;
}
// пример вызова

SetReadHandler(0, 0x7FF, ARAM);

  1. В коде ядра все функции чтения/записи вставляются в местах своего вызова как есть при помощи ключевого слова __inline.

static __inline uint8 RdMem(unsigned int A)
{
return(_DB=ARead[A](A));
}

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

Важные особенности эмуляции памяти — размер машинного слова в оригинальном регионе и порядок байт в этом слове. Если порядок big-endian, то сначала идет старший байт (most significant byte), как в привычной нам десятичной системе. Если little-endian, то сначала младший байт (least significant byte), и хотя для микропроцессоров как раз такой подход удобнее, человеку он непривычен. Эмулятор МегаДрайва (слово равно двум байтам) Gens для всего региона ROM при загрузке игры меняет в ней каждые два байта местами (swap), чтобы уравнять его параметры с прочими регионами и обращаться к ним всем одинаково. А от размера слова будет зависеть, какие указатели использовать для каждого региона: например для Си-подобных языков, если эмулируемая машина работает только с байтами, используется указатель на char, если слово равно двум байтам — на short, если четырем — на int. То есть при обращении к адресу памяти, компьютер автоматически будет работать с тем количеством байт, которое соответствует типу указателя.

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

Графика

Компьютерная графика бывает двухмерной и трехмерной. Из разновидностей двухмерной графики в консолях использовалась растровая (в частности, пиксель арт), причем строилась она из плиток (tile), размер которых обычно был 8 на 8 пикселей. Из этих тайлов составлялись слои, которые могли на экране двигаться независимо друг от друга — фон и спрайты. Память и быстродействие всегда были сильно ограничены в сравнении с желаемыми. Поэтому ставился упор на то, чтобы, имея одновременно загруженным минимальное число тайлов, максимальное их число использовать повторно — отсюда знакомые всем повторяющиеся узоры в играх. Трехмерная графика в консолях обычно основана на полигонах. То есть система работает с трехмерным пространством, где каждый объект (если это не спрайт, как в Думе) состоит из вершин, ребер, граней, полигонов, поверхностей и накладываемых на эти модели текстур. И в соответствии с тем, насколько хорошо программист знает тригонометрию и законы физики, эти модели движутся в пространстве и взаимодействуют друг с другом, а мы все это видим глазами двухмерной камеры.

Само по себе рисование на экране компьютера тайлов — дело нехитрое. Задаем координаты каждому из них и двигаем по экрану. Эффект наложения создается благодаря альфа каналу, позволяющему нам задавать прозрачность, а спрайтам — иметь какие-то другие очертания, кроме прямоугольных. Цвета задаются через ARGB (в разных сочетаниях), то есть альфа канал, красный, зеленый и синий. Компьютер может работать и с цветовой моделью, которую использовали ранние консоли — YUV (канал яркости и два цветоразностных), но почему-то так никто не делает.

Проблемы же начинаются, когда мы пытаемся создать в эмуляторе изображение, идентичное оригинальному на консоли:

  1. Невозможность выяснить, какие же в точности цвета нам эмулировать. Генерируемый консолью YUV сигнал проходит через декодер, превращаясь в RGB, испытывая искажения в яркости, насыщенности, контрасте, резкости. Цвета, выходящие из консоли, всегда отличаются от тех, что мы видим на телевизоре. Это даже описано шутливой (лишь отчасти) расшифровкой названия NTSC — never the same color.
  2. Синхронизация параллельно работающих видео- и центрального процессоров. Для получения аутентичных растровых эффектов недостаточно синхронизировать их только раз в кадр, следует это делать как минимум в конце каждой строки. Однако, для некоторых хитрых растровых эффектов синхронизация должна быть уже попиксельной. А для совсем упоротых игр (и в режиме отладки) — потребуется уже синхронизация каждый цикл!
  3. Эмуляция ограничений графической системы, особенно тех, которые неинтуитивно использовались пытливыми умами разработчиков игр (действующих по принципу «не баг, а фича»). Произведения деятелей дэмосцены хорошо это иллюстрируют: заставить консоль выдавать то, что простой пользователь не мог и представить, для них дело чести. Если эмулятор неточный, он запорется как на таких демках, так и на играх, где эксплуатировались те же принципы.
  4. Эмуляция особенностей системы, которые документированы плохо, противоречиво, ошибочно или никак. Для полноты картины придется заниматься декапом (разбором и фотографированием под микроскопом) оригинальны чипов и построением симулятора их работы.
  5. Сохранение при всем при этом вменяемой скорости работы эмулятора, не требующего топового компьютера. На старых машинах известный эмулятор Супер Нинтендо BSNES выдаст 60 кадров в секунду, только если не используется режим абсолютной точности.

Эмуляция видеопроцессора обычно состоит из трех этапов.

  1. Декодирование формата данных о пикселе, используемого консолью (у каждой консоли он могут быть свой уникальный), в понятный целевому компьютеру формат. То есть, из видеопамяти читаются биты и байты, обозначающие цвет каждого пикселя в тайле, и преобразуются в сигнал ARGB. Консоль может использовать разные принципы генерации цвета. Например, в видеопроцессор может быть вшита палитра доступных цветов, и игры будут использовать только индекс для указания номера нужного цвета в палитре (тогда эмулятор может отдельно хранить все цвета исходной палитры и так же по индексу подставлять их в нужный пиксель). А может быть так, что игры сразу используют формат RGB, если имеется поддержка сразу большого количества цветов (тут уж придется каждый раз их декодировать по отдельности). Также могут использоваться эффекты, создающие цвет, изначально в палитре не доступный. Вариантов крайне много, поэтому едем далее.
  2. Составление воедино всех слоев для каждой строки. Здесь важны приоритеты спрайтов и фона, то есть порядок их расположения относительно друг друга (z-order). Это будет влиять на то, в каком порядке они рисуются в промежуточный буфер (массив байтов, хранящий значения итоговых цветов). Игры могут использовать это для хитрых эффектов, например, в игре Jackie Chan’s Action Kung Fu сначала рисуются темные колонны в слое фона, потом яркие колонны с высоким приоритетом в слое спрайтов, потом спрайты персонажей с низким приоритетом (поверх ярких колонн). Когда все это пересекается, яркий спрайт колонны перекрывается спрайтом персонажа, а тот перекрывается фоновой колонной (так как у него низкий приоритет). В итоге мы видим эффект силуэта. Также консоль может при большом количестве спрайтов в строке использовать их мерцание каждый кадр, чередуя спрайты, чтобы в итоге видно было их все.
  3. Составленная из сканлайнов в целой кадр картинка (массив байтов) блитится (blitting) в итоговый буфер, который будет представлен пользователю. Это уже делается видеокартой целевого компьютера, используя методы DirectX или OpenGL. При этом, изображение может быть подвержено дополнительным эффектам (фильтрация), а также изменено в размере. Так как это делается видеокартой, процесс этот довольно быстрый, поскольку разгружается основной процессор. А есть ситуации, когда вообще всю генерацию картинки проще делать силами видеокарты, например отрисовка полигонов: современные карты заточены как раз на ускорение этого процесса, а также позволяют добавлять различную фильтрацию текстурам, сглаживать грани полигонов, увеличивать их масштаб, и еще тысячей способов улучшать картинку. Все это можно, конечно, делать и на стороне CPU, но получим огромное замедление. Хотя тогда результат не будет зависеть от новизны видеокарты.

Звук

Звук может вырабатываться двумя способами: генерация и сэмплирование. В первом случае, сам звуковой чип синтезирует тон, позволяя программисту (и, желательно, музыканту по совместительству) менять различные параметры этого синтеза (частота, громкость, скважность, фаза, форма волны). Во втором, в игру закатывается специальным способом закодированный отрезок звука нужной длительности (например, по одной ноте на каждый инструмент, либо звуковой эффект целиком), и приставка просто воспроизводит его на нужной высоте, зацикливая, если надо.

Важной особенностью звука в консолях является частота семплирования (дискретизации), то есть частота обновления амлитуды (громкости) сигнала. Вообще, слово сэмпл обозначает отрезок оцифрованного звука, и относится как к целому звуковому фрагменту (массиву), так и к каждой минимальной его части (элементу массива). Аналоговый звук непрерывен и может передать любую частоту, но при оцифровке возможно лишь выхватывать отдельные значения амплитуды с ограниченной скоростью, поэтому цифровой звук всегда дискретен (гуглим). И чтобы воспроизвести звук нужной частоты, нам надо обновлять амплитуду сигнала с частотой вдвое большей (как минимум). Например, семпл с частотой дискретизации 40 кГц может содержать звук с частотами до 20 кГц

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

Самым простым способом эмуляции звукового чипа-синтезатора (PSG, programmable sound generator) будет запись сэмплов в буфер на той частоте дискретизации, на которую способен сам чип. Например, треугольный канал приставки NES способен выдать максимальную частоту тона 55,9 кГц, что потребует частоты дискретизации 111,8 кГц. Однако, работать с такими значениями непрактично (да и услышать такие частоты невозможно), поэтому все эмуляторы применяют разные методы передискретизации, то есть конвертируют сигнал, меняя частоту его дискретизации на традиционные значения (44,1 кГц, 48 кГц, 96 кГц). Могут применяться еще и дополнительные фильтры на усмотрение автора, так как оригинальный звук тоже всегда подвергается фильтрации внутри консоли.

Генерация квадратной волны (стандартной для 8-битных консолей) сводится к записи значения амлитуды в буфер в течение времени, соответствующего длине волны. Таймер, считающий, сколько должен длиться период волны относительно циклов CPU, в разных консолях разный. Вот его формула для NES:
timer = (cpu_freq/(16*note_freq)) — 1

Чтобы получить ноту Ля (440 Гц) от приставки NES (1.79 МГц), мы должны в таймер квадратной волны (тип волны первых двух каналов) записать 253. Следует еще учитывать скважность, чтобы знать, какую часть периода волны требуется заполнять высоким уровнем, а какую низким. В случае треугольного и пилообразного каналов, надо не повторять запись одного и того же значения, а чередовать уровни амплитуды соответствующим образом. Шумовой канал создает псевдослучайные скачки амплитуды на заданной частоте.

Более совершенные звуковые чипы были способны на частотно-модуляционный (FM) синтез. То есть берется волна определенной формы (обычно синусоидальная) и ее частота модулируется амплитудой другой волны. В итоге частота несущей волны (carrier) будет меняться в точном соответствии с изменениями амплитуды волны модулирующей. Если частота модулирующей волны низкая, мы услышим изменение высоты тона у несущей волны. Если же частота достаточно высокая, мы услышим изменение тембра.

Вот формула получения сэмпла звука на момент времени t (префикс c_ соответствует несущей волне, а префикс m_ — модулирующей, amp — амплитуда, angular_freq — угловая частота):

F = c_amp*sin(c_angular_freq*t + m_amp*sin(m_angular_freq*t))

А вот ее реализация в Genesis+GX:

inline signed int op_calc(
unsigned int phase,
unsigned int env,
unsigned int pm
) {
unsigned int p = (env<<3) + sin_tab[
((phase >> SIN_BITS) + (pm >> 1)) & SIN_MASK
];
if (p >= TL_TAB_LEN) return 0;
return tl_tab[p];
}

Так как это уже матан, ограничимся несколькими замечаниями:

  1. Технология частотной модуляции, использованная Ямахой для синтеза звука и представленная миру под именем FM синтеза, была на самом деле фазовой модуляцией.
  2. При достаточно гладких модулирующих функциях формы итоговых сигналов фазовой и частотной модуляции практически идентичны.
  3. Так как фазовую модуляцию проще реализовать программно, в эмуляторах используется именно она, даже если там ее назвали FM синтез.
  4. В примере выше используются готовые таблицы значений синусоиды для обоих сигналов, причем эталон синусоиды был вшит прямо в звуковой чип MegaDrive (YM2612, тоже от Ямахи) также в виде таблицы.
  5. Количество волн, последовательно модулирующих друг друга (операторов), может быть любым. FM синтезатор MasterSystem позволяет смешать таким образом только две волны, а мегадрайвовский — до четырех.

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

NES может использовать два вида семплов: однобитные и семибитные. В первых изменение уровня исходного сигнала закодировано одним битом, то есть он либо понижается на одну ступень, либо повышается. Для этого сравниваются текущие уровни исходного и закодированного сигналов, и если исходная амплитуда выше — в закодированный следующим пишется 1, иначе 0. Такие семплы практически ничего не весят, но звучат относительно разборчиво. Частота дискретизации у них стандартная, так как NES способна в фиксированные интервалы времени сама воспроизводить новую порцию битов. Семибитные же семплы имеют такую точность соответствия исходному сигналу, какую захочет разработчик, так как, во-первых, все закодировано 7-ю битами (128 уровней), во-вторых, автор игры сам решает, когда воспроизвести новый отрезок семпла, хоть каждую инструкцию.

В более поздних консолях, где уже не требовалось так параноидально экономить, форматы семплов были ближе к тем, которые известны современным компьютерам.

При воспроизведении семплы иногда подвергаются изменению высоты тона (pitch) (так Наоки Кодака создал свой знаменитый семплированный бас, а SNES пыталась имитировать электрогитару), фильтрации (интерполяция звука в SNES, реверберация в PlayStation) или зацикливанию для увеличения длительности ноты.

После генерации семплов каждого инструмента их необходимо смикшировать. Это делается сложением амплитуд всех исходных каналов. Однако, это не стадион, где может одновременно кричать несколько тысяч человек, и верхней границы громкости не будет. В цифровом звуке она есть, это уже упомянутая битность. Допустим, мы работаем с 8-битным звуком. Если  в результате сложения каналов получается амплитуда больше максимально допустимой (например, 400), начинаются сложности, так как приходится придумывать способы сохранить информацию и донести в максимально точной форме. Просто делить итоговую амплитуду, пока она не влезет, нельзя: каждый канал начнет звучать тише, и потеряются нюансы, на которые он тратит все доступные ему уровни громкости (256). Можно просто ограничивать амплитуду каждого семпла до максимально допустимой (clamp), но так тоже теряются характеристики звука. То же самое с отсечением всех чрезмерных амплитуд. Какое решение будет универсальным? Матан! И мы его снова пропустим.

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

Ввод

На уровне ядра ввод обычно реализуется функциями записи в память (в регистры ввода), и раз в кадр ожидается поступление от клиента информации о том, какие кнопки нажаты (каждая кнопка обычно кодируется одним битом), и каково состояние аналоговых устройств управления. Тогда игра, читая соответствующие адреса памяти, сама все сделает как надо.

Поддерживаемые консолью устройства ввода могут быть самыми экзотическими, как и поддерживаемые клиентом (при помощи соответствующего драйвера), поэтому перестанем забивать себе голову и перейдем к заключению.

Заключение

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

Ладно, уговорили, вот на русском:

Автор статьи: feos