image

Всем привет! С вами на связи снова Сергей, и я продолжаю творить «чудо».

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

▍ Отступление


Я пока копаюсь на просторах интернета, нахожу постоянно какую-то информацию. И кроме информации на сайте nesdev (где достаточно немало информации), нашёл вот такой сайт с документами (пробегаясь позже по сайту, я понял, что уже заходил на него, но вот вкладку «документы» не открывал).

Несколько книжек по программированию на веб-архиве:


И ресурс Easy 6502, где человек показывает, как можно потренироваться в программировании.

Зачем я даю ресурсы о программировании, если мы делаем эмулятор? Всё достаточно просто: ассемблер наиболее близок к архитектуре процессора. И, зная ассемблер, а также схемотехнику, можно понять, как работают инструкции, прерывания, вызовы, память, устройства и другое. Поэтому программирование именно на ассемблере желательно знать!

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

Также можете почитать мою статью, о том, что нужно для создания эмуляторов. Но там мало что написано — в основном о том, с какими подводными камнями вам придётся столкнуться.

▍ Память и работа с ней


Изначально мы реализовали память как простой массив данных, но в приставках Dendy/Nes/Famicon память «разделена на части».

Адрес

Размер

Назначение

0000-07FF

2k

RAM

0800-1FFF

6k

RAM Mirror (x3)

2000-2007

$8 = 8 байт

Registers Video

2008-3FFF

$1FF8 = 8184 байта

Registers Video Mirror (x1023)

4000-4017

$20 = 32 байта

Registers Audio & DMA & I/O

4018-4FFF

$0FE8 = 4072 байта

Not used

5000-5FFF

4k

Expansion ROM\RAM (etc. in MMC5)

6000-7FFF

8k

SRAM (aka WRAM) (etc. in MMC3)

8000-BFFF

16k

PRG-ROM (1)

C000-FFFF

16k

PRG-ROM (0)

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

function readMem(addr: Word): Byte;
begin
  if addr < $4000 then
    if addr < $2000 then
      addr := addr and $7FF        // оставляем только одну область
    else
      addr := addr and $2007;      // оставляем только одну область
  Result := Memory[addr];
end;

procedure writeMem(addr: Word; data: Byte);
begin
  if addr < $4000 then
    if addr < $2000 then
      addr := addr and $7FF        // оставляем только одну область
    else
      addr := addr and $2007;      // оставляем только одну область
  if (addr < $6000) and (addr >= $5000) then
    exit;
  Memory[addr] := data;
end;

function testMem(addr: PLongWord): Boolean;
begin
  // этой функцией надо пользоваться осторожно! Если мы её применяем к регистру PC, то данные в регистре могут измениться.
  // А этого происходитьне должно!
  Result := True;
  if addr^ < $4000 then
    if addr^ < $2000 then
    begin
      addr^ := addr^ and $7FF;        // оставляем только одну область
      exit;
    end
    else begin
      addr^ := addr^ and $2007;      // оставляем только одну область
      exit;
    end;
  if (addr^ < $6000) and (addr^ >= $5000) then
  begin
    Result := False;
    exit;
  end;
  addr^ := addr^ and $FFFF;
end;


функция testMem сделана для того, чтоб не вызывать подряд функции/процедуры readMem и writeMem. И… мы не будем везде использовать данные функции/процедуры.

Эмулятор использует разные режимы адресации, и для определённых режимов использовать созданную процедуру и функции надо. А для многих режимов не надо. Например:

  • Режим непосредственной адресации (IMM) не требует использования данных функций. Данные читаются сразу после инструкции.
  • Режим адресации нулевой страницы (ZP, ZPX, ZPY) также не требует использования данных функций, здесь всегда адрес не более 255 (небольшое уточнение: инструкции, работающие с ZPX и ZPY, могут выходить за пределы нулевой страницы, но дальше первой страницы уже не смогут указывать, а точнее будет чтение/запись в область стека).

Исходя из всего, остаются только прямая (абсолютная) адресация (ABS, ABX, ABY), индексно-косвенная адресация (NDX) и косвенно-индексная адресация (NDY). Именно в них и будет в основном использоваться данная функциональность.

Если вы решили спросить меня: «А как же режим аккумуляторной адресации (ACC) и режим неявной адресации (IMPL)?». Ответ достаточно прост: функции, использующие эту адресацию, однобайтовые, и эти инструкции не работают с памятью (имеется в виду общая память).

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

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

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

Удобство использования памяти при таком подходе теряется. До этого она была линейной, а после данной реализации работу с ней необходимо стало контролировать, и мы получаем накладные расходы на вычисление адреса памяти. К сожалению, чем-то жертвовать всё равно приходится.

Объявление памяти и видеопамяти
var
  pageVMem: LongWord;                              // активная видеостраница.

// видеопамять
  vRAM: array of array [0..$3FF] of Byte;
  videoMem: array [0..3] of PByteArray;            // видеопамять с $2000 по $2FFF. 4 банка.
  videoRAM: array [0..$1FFF] of Byte;              // видеопамять с $3000 по $3FFF.
  videoCIRAM: array of array [0..$1FFF] of Byte;   // массив переключаемых страниц видеопамяти. Это CHR-ROM.

// память
  pageMem: array [0..7] of LongWord;               // массив переключаемых страниц.
  Memory: array of PByteArray;                     // страницы памяти, неопределённое количество, зависит от загружаемых данных.

(* $4000-$5FFF — дополнительная память, используется в разных целях, например, для взаимодействия с аудио, контроллерами. $6000-$7FFF - память «для сохранений». $8000-$FFFF - различная память RAM, ROM. Эти страницы обычно и переключаются. *)

// Изменение работы с памятью на примере readMem и writeMem.
function readMem(addr: Word): Byte;
begin
  if addr < $4000 then
    if addr < $2000 then
      addr := addr and $7FF        // оставляем только одну область
    else
      addr := addr and $2007;      // оставляем только одну область
  Result := Memory[pageMem[addr shr 13], addr and $1FFF];
end;

procedure writeMem(addr: Word; data: Byte);
begin
  if addr < $4000 then
    if addr < $2000 then
      addr := addr and $7FF        // оставляем только одну область
    else
      addr := addr and $2007;      // оставляем только одну область
  if (addr < $6000) and (addr >= $5000) then
    exit;
  Result := Memory[pageMem[addr shr 13], addr and $1FFF];
end;


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

▍ Загрузчик


Хотелось бы сразу перейти к реализации видеоадаптера, но очень многое упирается в то, что проверить работоспособность процессора не выйдет, а также не выйдет проверить работоспособность видеоадаптера и любых других устройств эмулируемой системы. Надо либо писать код самому и загружать его, либо брать готовый. И потому как бы ни хотелось этим заниматься, но всё равно придётся (и мне пришлось!) браться за реализацию загрузки данных формата *.nes.

При реализации загрузчика надо изучить, как устроен формат iNes, Nes 2.0 и, если вы решитесь браться за какие-то старые форматы либо нестандартные, то также изучить и их (стоит или нет — решать вам).

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

Код загрузчика

function LoadNes(fname: UTF8String): Boolean;
const
  iNes07Format  = $01;
  iNESFormat    = $02;
  iNESarchaic   = $01;             // они используются?
  NES20Format   = $04;

type
  TNESHeader = record
    Header  : Array[0..2] Of Char;
    Escape  : Byte;
    PRGPages: Byte;
    CHRPages: Byte;
    _byte   : Array[6..15] Of byte;
  end;

var
  mem: zglTMemory;
  NESHeader: TNESHeader;
  dataTrainer: array[0..511] of Byte;
  prgSize: LongWord;                    // общее количество банков памяти
  chrSize: LongWord;                    // общее количество банков видеопамяти
  n: QWord;
  i: Integer;
begin
  Result := True;
  mem_LoadFromFile(mem, fname);
  mem_Read(mem, NESHeader, 16);          // SizeOf(TNESHeader);
  with NESHeader, NesCPU.RomInfo do
  begin
    if (Header <> 'NES') or (Escape <> $1A) then
    begin
      Result := False;
      log_Add('Неизвестный формат файла. Не «NES»!');
      exit;
    end;
    if (_byte[7] and $0C) = 8 then
      nesFormat := NES20Format
    else
      // видимо поддержки iNes07 и iNESarchaic не существует, потому может лучше попробовать обычный iNes использовать?
      if ((_byte[7] and $0C) = 0) and (_byte[12] = 0) and (_byte[13] = 0) and (_byte[14] = 0) and (_byte[15] = 0) then
        nesFormat := iNESFormat
      else
        nesFormat := iNes07Format;
    // mapper
    case nesFormat of
      // Информация о мапперах хранится в разных байтах. И довольно странно...
      // Было бы проще перетащить в последовательные данные, но формат очень старый. Это нарушит совместимость.
      iNes07Format: MapperID := _byte[6] shr 4;
      iNESFormat:   MapperID := (_byte[7] and $F0) or (_byte[6] shr 4);
      NES20Format:  MapperID := ((_byte[8] and $0F) * 256) or (_byte[7] and $F0) or (_byte[6] shr 4);
    end;

    if (nesFormat and NES20Format) > 0 then
    begin
      // submapper
      SubMapperID := (_byte[8] and $F0) shr 4;
      // NESSystem;
      case (_byte[7] and 3) of
        0:  case (_byte[12] and 3) of
              0, 2: NESSystem := NES_NTSC;
              1: NESSystem := NES_PAL;
              3: NESSystem := Dendy;
            end;
        1: NESSystem := VsSystem;
        2: NESSystem := Playchoice;
        3:  case _byte[13] of
              1: NESSystem := VsSystem;
              2: NESSystem := Playchoice;
              else
                NESSystem := NES_NTSC
            end;
      end;
      // видеопамять       CHR-RAM
      if (_byte[11] and $0F) > 0 then
        ChrRamSize := 64 shl (_byte[11] and $0F)
      else
        ChrRamSize := 0;
      // сохранённая видеопамять   CHR-NVRAM
      if (_byte[11] and $F0) > 0 then
        BatteryChrRamSize := 64 shl (_byte[11] and $F0)
      else
        BatteryChrRamSize := 0;
      // PRG-RAM
      if (_byte[10] and $0F) > 0 then
        WorkRamSize := 64 shl (_byte[10] and $0F)
      else
        WorkRamSize := 0;
      // PRG-NVRAM/EEPROM
      if (_byte[10] and $F0) > 0 then
        BatteryRamSize := 64 shl (_byte[10] and $F0)
      else
        BatteryRamSize := 0;

      if (_byte[9] and $0F) = $0F then
      begin
        n := PRGPages shr 2;                                                    // вероятнее всего, дальнейшие вычисления должны дать результат
        if n > 60 then                                                          // кратный $2000, $4000 или $8000
          n := 60;
        prgSize := LongWord(((PRGPages and 3) * 2 + 1) * QWord(1 shl n));       // надо будет определиться, как с такими данными работать?
      end
      else
        prgSize := (((_byte[9] and $0F) shl 8) or PRGPages) * $4000;

      if (_byte[9] and $F0) = $F0 then
      begin
        n := CHRPages shr 2;                                                    // а здесь кратным $1000 или $2000
        if n > 60 then
          n := 60;
        chrSize := LongWord(((CHRPages and 3) * 2 + 1) * QWord(1 shl n));       // надо будет определиться, как с такими данными работать?
      end
      else
        chrSize := (((_byte[9] and $F0) shl 4) or CHRPages) * $2000;
    end
    else begin
      // submapper
      SubMapperID := 0;
      // NESSystem;
      if (_byte[7] and 1) > 0 then
        NESSystem := VsSystem
      else
        if (_byte[7] and 2) > 0 then
          NESSystem := Playchoice
        else
          if (_byte[9] and 1) > 0 then
            NESSystem := NES_PAL
          else
            NESSystem := NES_NTSC;      // тут должен быть «unknown», но заменяю пока данным значением.
      // видеопамять
      ChrRamSize := 0;
      // сохранённая видеопамять
      BatteryChrRamSize := 0;
      WorkRamSize := 0;

      prgSize := $2000;
      chrSize := CHRPages * $2000;
    end;
    // mirror
    if (_byte[6] and 8) <> 0 then
    begin
      Mirroring := MIRROR_FOURSCREENS;  // 4 страницы (обычно)
      NesPPU.regScreen := $2FFF;
      NesPPU.PPUflags := NesPPU.PPUflags or VIDEO_PAGES_4;
    end
    else begin
      Mirroring := _byte[6] and 1;      // MIRROR_VERTICAL or MIRROR_HORIZONTAL
      NesPPU.regScreen := $27FF;
    end;
    Battery := (_byte[6] and 2) > 0;
    Trainer := (_byte[6] and 4) > 0;

    // выставляем номера страниц, пока все последовательно.
    for i := 0 to 7 do
      pageMem[i] := i;

    // дальнейшие вычисления должны производиться согласно используемого маппера. Это заглушка.
    // создать память до загрузки
    if PRGPages = 1 then
    begin
      SetLength(Memory, 8);       // только при одной странице данные будут одинаковы в «двух страницах»
      SetLength(RAM, 8);
      for i := 0 to 7 do          // количество страниц +
        // выставляем начальные и «рабочие» страницы памяти.
        Memory[i] := @RAM[i];     // 0 = $0000-$1FFF     1 = $2000-$3FFF    2 = $4000-$5FFF    3 = $6000-$7FFF
                                  // 4 = $8000-$9FFF     5 = $A000-$BFFF    6 = $C000-$DFFF    7 = $E000-$FFFF
    end
    else begin
      SetLength(Memory, 4 + PRGPages * 2);          // везде же будет загружаться память в разные страницы.
      SetLength(RAM, 4 + PRGPages * 2);
      for i := 0 to PRGPages * 2 + 3 do               // количество страниц +
        // выставляем начальные и «рабочие» страницы памяти.
        Memory[i] := @RAM[i];                         // 0 = $0000-$1FFF     1 = $2000-$3FFF    2 = $4000-$5FFF    3 = $6000-$7FFF
                                                      // 4 = $8000-$9FFF     5 = $A000-$BFFF    6 = $C000-$DFFF    7 = $E000-$FFFF
    end;

    if Trainer then
      mem_Read(mem, Memory[3, $1000], 512);         // проверку на чтение делать?  это надо загрузить в адрес $7000

    mem_Read(mem, Memory[pageMem[4], 0], prgSize);  // последний банк памяти обычно ПЗУ (статично).
    mem_Read(mem, Memory[pageMem[5], 0], prgSize);
    if PRGPages = 1 then
      mem_Seek(mem, -(prgSize * 2), FSM_CUR);       // второе чтение, того же кода, если банк памяти был один.
    mem_Read(mem, Memory[pageMem[6], 0], prgSize);

    if mem_Read(mem, Memory[pageMem[7], 0], prgSize) = 0 then
    begin
      // Это для нулевого маппера. Будут ли ещё подобные игры? Будет ли подобное в других мапперах?
      pageMem[4] := 4;      // пробуем просто поставить другие банки.
      pageMem[5] := 5;      // странное расположение и разбивка по 8 кб.
      pageMem[6] := 4;
      pageMem[7] := 5;
      PRGPages := 1;
      CHRPages := 1;
      mem_Seek(mem, -prgSize, FSM_CUR);
    end;

    // видеопамять создаём в любом случае.
    if CHRPages = 0 then
    begin
      SetLength(videoCIRAM, 1);                     // две рабочих страницы
      NesPPU.lenVRam := 1;
    end
    else begin
      SetLength(videoCIRAM, CHRPages);              // количество страниц со знакогенераторами.
      NesPPU.lenVRam := CHRPages;
    end;
    // в общем, ситуация такова, что получается, если CHRPages больше
    // 1, то надо выделять для каждого банка отдельную память, чтобы
    // можно было их переключать.
    // все страницы CHRPages размером по $2000.
    for i := 0 to CHRPages — 1 do
    begin
      mem_Read(mem, videoCIRAM[i], $2000);
      // сразу добавляем данные, даже если они не инициализированы.
      texCreateCHRROM(PByteArray(@videoCIRAM[i, 0]), PByteArray(@videoCIRAM[i, $1000]));
    end;
    if CHRPages = 0 then
      NesPPU.PPUflags := NesPPU.PPUflags or NO_CREATE_TEXTURE;
    pageVMem := 0;
    _PRGPages := PRGPages;
    _CHRPages := CHRPages;
    if PRGPages > 2 then
      NesCPU.CPUflags := NesCPU.CPUflags or NES_CHANGE_MEM;
    if CHRPages > 1 then
      NesCPU.CPUflags := NesCPU.CPUflags or NES_CHANGE_VMEM;
  end;
  mem_Free(mem);

  // устанавливаем фреймы для всех текстур.
  Texture0_SetFrameSize;
end;


Не надо просто копировать код — велика вероятность, что он не будет у вас работать без редактирования, тем более что со временем данный код будет меняться. Код нужен для того, чтобы вы могли сравнить его с другим и решить, как вам его реализовывать. К этому коду мы делаем структуру для загрузки данных и дальнейшей работы с ними (если что, все данные у меня в файле pz_data.pas, а данный код в файле pz_utils.pas):

TRomInfo = record
    // пока вношу всю информацию сюда, дальше нужно будет понять, что с ней делать.
    // никаких размерностей меньше размерности данных выбранной архитектуры надо стараться не делать!
    // А так как пока всё делается только для 64-х и 32-х битных систем, то больше никаких данных не надо.
    nesFormat: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    _PRGPages: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    _CHRPages: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    MapperID: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    SubMapperID: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    Mirroring: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};                                                        // надо отправлять в PPU ?
    Battery, Trainer: Boolean;
    NESSystem: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    ChrRamSize, BatteryChrRamSize: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
    WorkRamSize, BatteryRamSize : {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};
  end;


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

Сейчас работа будет происходить только с одним маппером — 000. И, конечно же, я сразу захотел проверить работоспособность процессора, и было не важно, что видеоадаптер ещё не работает. Взял игру Bomberman и загрузил её.

Ну, понятно, что без напильника не обошлось, и сразу всё не заработало, но, подрихтовав немного, всё запустилось, и я хотел проверить процессор. И увидел фигу… Код циклично скачет по одним и тем же адресам…

Ну всё, думаю, где-то нахимичил… Запустил игру в Fceux посмотреть, что же там происходит… и увидел, что там код ожидания синхронизации с видеоадаптером, который ещё не реализован. ))) Да, некоторые инструкции работают, и вроде как верно, а значит, всё не так плохо как я предполагал, и можно продолжать дальше!

На момент написания статьи эмулятор уже работает. Не полноценно, но работает! Многие ошибки в процессоре и PPU исправлены.

▍ «Видеопроцессор» (PPU)


Для создания PPU надо произвести синхронизацию работы PPU с CPU и произвести сам вывод данных на экран. Сколько же всего тут надо изучить… циклы, рендеринг, тайминги, синхронизацию кадров и, конечно же, реализацию на разных эмуляторах.

▍ Как именно будет реализовано?


Нет, так делать не будем.
Мы возьмём общее количество тактов PPU и будем работать от них. Каждая строка длится 341 такт и полных линий отображения 262. Всего получится 89341.5 (ну там какая-то фигня с кадрами, где один следует за другим и один из них короче на 1 такт...).

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

Все циклы CPU мы будем делать в одном нашем цикле с частотой 50-60 Гц. Нам этого хватит за глаза и не надо будет высчитывать каждый такт. Мы просто за инструкцию будем вычитать её время исполнения, и когда дойдём до нуля начнём «новый кадр» и новый отсчёт.

Ну и… в этом же цикле мы будем обрабатывать и PPU. Почему? Я думаю, у меня получится сделать всё для того, чтобы мы не тратили времени на работу с PPU, пусть основной частью рендеринга занимается OpenGL.

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

  • «Игровые приставки», Королёв А.Г. Выпуск 21;
  • сайт Мигера;
  • сайт Nesdev, здесь очень много информации (также можно на их форуме поискать информацию).

Дальше мы будем опираться на то, что вы всё же изучили информацию. Или смотрите её параллельно, читая предоставленную мной статью (без упомянутой информации, она — Филькина грамота).

Память PPU.

Адрес

Размер

Назначение

$0000-$0FFF

4k

CHR-ROM Знакогенератор 0 (На картридже)

$1000-$1FFF

4k

CHR-ROM Знакогенератор 1 (На картридже)

$2000-$23BF

$3C0 = 960 байт

VRAM Экранная страница 1 – Символы (В приставке)

$23C0-$23FF

$40 = 64 байта

VRAM Экранная страница 1 – Атрибуты (В приставке)

$2400-$26BF

$3C0 = 960 байт

VRAM Экранная страница 2 – Символы (В приставке)

$27C0-$27FF

$40 = 64 байта

VRAM Экранная страница 2 – Атрибуты (В приставке)

$2800-$2BBF

$3C0 = 960 байт

VRAM Экранная страница 3 – Символы (На картридже)

$2BC0-$2BFF

$40 = 64 байта

VRAM Экранная страница 3 – Атрибуты (На картридже)

$2C00-$2FBF

$3C0 = 960 байт

VRAM Экранная страница 4 – Символы (На картридже)

$2FC0-$2FFF

$40 = 64 байта

VRAM Экранная страница 4 – Атрибуты (На картридже)

$3000-$3EFF

$F00 = 3840 байт

VRAM Mirror of $2000-2EFF

$3F00-$3F0F

$10 = 16 байт

Палитра фона (в регистрах PPU)

$3F10-$3F1F

$10 = 16 байт

Палитра спрайтов (в регистрах PPU)

$3F20-$3FFF

$E0 = 224 байт

Не используются

Дальнейшая информация утрирована! Не надо её рассматривать, не изучив информацию о PPU и его работе. Опору я делаю на то, что вы уже всё знаете и автоматически исправляете мои ошибки.

Допустим, CHR-ROM может нести информацию как о спрайтах, так и о тайлах или о тайлах и спрайтах одновременно. Постарайтесь учитывать эти моменты.

Рассматривая область видеопамяти, а в частности CHR-ROM — это можно сказать основная часть, которая несёт графическую информацию. С адреса $0000 по адрес $0FFF (первый знакогенератор) идёт информация о спрайтах/тайлах. Все спрайты/тайлы всегда расположены последовательно друг за другом. Нулевой байт и 8-й образуют верхнюю строчку спрайта, а всего таких строчек восемь (0 и 8 — первая строка, 1 и 9 — 2-я строка, 2 и 10 — 3-я строка… 7 и 15 — 8-я строка спрайта). Итого на один спрайт уходит 16 байт, и в память влезает 256 спрайтов (всего 4096 байт). Это только на указание графического вида спрайта. Следующий спрайт состоит из следующих 16 байт с 16-го по 31-й и так далее.

Рассмотрим первый спрайт. Из двух байт (возьмём 0 и 8) слева-направо считываются биты по одному из каждого и два полученных бита составляют число от 0 до 3 — это первый, общий пиксель спрайта (будем считать это за полноценный пиксель). Дальше читаются ещё два бита из двух байт и ещё и ещё… пока не будут прочитаны все данные для 8 пикселей спрайта. В итоге получаем верхнюю линию первого спрайта.

Два байта считано и следующая линия состоит из следующих байт (1 и 9), и так до конца спрайта. Иииии! Далее они всё так же будут считываться друг за другом — последовательно. Данная реализация позволяет достаточно быстро рассчитать нужный спрайт видеоадаптеру и вывести его в нужное знакоместо (а вот программисту для этого надо изощрится).

Битовые плоскости
$0xx0=$41 01000001... $0xx8=$01 00000001
$0xx1=$C2 11000010... $0xx9=$02 00000010
$0xx2=$44 01000100... $0xxA=$04 00000100
$0xx3=$48 01001000... $0xxB=$08 00001000
$0xx4=$10 00010000... $0xxC=$16 00010110
$0xx5=$20 00100000... $0xxD=$21 00100001
$0xx6=$40 01000000... $0xxE=$42 01000010
$0xx7=$80 10000000... $0xxF=$87 10000111

Конечный результат (00 = 0, 01 = 1, 10 = 2, 11 = 3)
00 01 00 00 00 00 00 11
01 01 00 00 00 00 11 00
00 01 00 00 00 11 00 00
00 01 00 00 11 00 00 00
00 00 00 11 00 10 10 00
00 00 11 00 00 00 00 10
00 11 00 00 00 00 10 00
11 00 00 00 00 10 10 10

Дальше к полученной информации мы должны добавить цвет. А цвет хранится отдельно от самих спрайтов, в палитре. А вот указание на палитру хранится в области атрибутов экранной страницы $3F00-$3F0F для фона, и $3F10-$3F1F для спрайтов. Берётся байт из указанной области, разбивается на 4 части по 2 бита, производится выборка из этих 4 частей и полученные данные заносятся в биты 2 и 3 результирующего байта. Также в этот результирующий байт заносятся ранее вычисленные данные для спрайтов — те, что рассчитывали из двух байт (ранее). Оттуда берётся каждый пиксель (два бита) и заносится в 0 и 1 биты результирующего байта. Как результат, получается 4-разрядное число, которое позволяет выбрать цвет из адреса фона ($3F00-$3F0F) или адреса спрайтов ($3F10-$3F1F).

Уже из полученного конечного ($3F00-$3F0F или $3F10-$3F1F) адреса берётся байт (точнее 5 бит), образующий номер которого указывает на то, какой цвет надо выбрать из настоящей палитры.

Для изучения как атрибуты берутся для расчёта цвета, можно посмотреть здесь. Учтите, что на данном ресурсе показано всё без скроллинга. Если экран скроллируется, то и атрибуты скроллируются вместе с ним.

Для «напоминания»: 0-й цвет из палитры фона ($3F00) — это прозрачный цвет (всего в данный момент используется 4 цвета, где один, самый первый — прозрачный), и, как итог, получается, надо вывести только 3 цвета, остальное — задний фон.

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

▍ Размышления о реализации


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

Второй вариант я рассматривал с использованием двух текстур, где одна совпадала бы с данными «левой» половины, а вторая «правой» (два байта одной строки спрайта, например 0 и 8). Вроде бы удобно — берём, накладываем друг на друга и проблемы решены! Но нет, не решены. В случае наложения текстур мы не можем сделать выборку по третьему цвету — третий цвет мы или потеряем, или он будет неправильным (произойдёт смешивание двух первых цветов).

Делать нечего, и я решил сделать три текстуры, где для каждой текстуры будет свой цвет и при наложении они не повлияют друг на друга, так как координаты точек у текстур не будут совпадать (для данного спрайта/тайла).

В дальнейшем я и для OpenGL реализовал всё с помощью одной большой текстуры, потому что пришлось. Иначе у меня получалась очень плохая производительность на Android.

В чём отличие реализации, которую предлагаю, я от той, что используется повсеместно? Все, кто изучал работу Nes, знают, что CPU и PPU работают параллельно (одновременно). В данном же случае работа их будет производиться последовательно: сначала отработает CPU и только потом PPU. Процессору надо будет только передать некоторые сигналы на согласование с PPU. Меня интересовала возможность работы в одном потоке, потому этим и задался.

Можно ли из данной реализации сделать параллельную реализацию? Да, конечно можно (может даже займусь как-нибудь этим). Только надо помнить, что для OpenGL должен выделяться всегда основной поток.

▍ Лицензия


Прежде чем переходить к реализации.

На технологию создания PPU и всё что с ней связано наложена лицензия MIT:

Лицензия
Name of technology: Nes PPU emulator (technology).
Author: Serge Shutkin
License: MIT
The MIT License (MIT)
Copyright © 2024-2025 Serge Shutkin
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Примечание: лицензия установлена на технологию PPU и всё что с ней связано! Изменение кода не влечёт за собой изменение технологии создания эмулятора PPU.

Данная технология относится ко всем схожим реализациям PPU, таким как: Nes, SNes, Game Boy и им подобным.

Note: the license is installed on the PPU technology and everything connected with it! Changing the code does not entail changing the technology of creating the PPU emulator.

This technology applies to all similar implementations of PPU, such as: Nes, SNes, Game Boy and the like.


▍ Реализация


Для реализации PPU я достаточно неплохо изучил информацию о нём. Все предыдущие реализации, что я видел, основаны на том, что во время работы CPU производится работа и с PPU. Я же задался целью передать все данные, которые должны были быть выведены с начала рендеринга PPU по момент прихода прерывания NMI, передавая данные OpenGL. Быстрее это будет или нет, я не могу сказать, потому что даже по сей день PPU ещё не полностью готов и мне приходится реализовывать всю необходимую функциональность для его работы и исправлять все выползающие баги и мелочи. При этом постараться, чтобы изображение формировалось правильно (как в настоящем устройстве). Ну а чем больше я стремлюсь к большей точности, тем тяжелее становится код, что больше нагружает процесс рендеринга (и не только).

Если вы уже изучили многое, что связано с PPU, то, думаю, дальше будет проще понять саму реализацию, которую я предлагаю. Итак, что мы знаем:

  • PPU производит вывод информации на экран, в то же время когда работает CPU. Когда изображение выведено на экран, приходит сигнал NMI, который оповещает, что в это время в видеопамять можно писать информацию.
  • Основа спрайта лежит в 16 байтах (результат сложения байт очень похож на текстуру).
  • В адресах $0000-$1FFF содержатся два знакогенератора, в каждом из которых может быть записана информация для 256 спрайтов.
  • В адресах $2000-$2FFF может находится до 4 экранных страниц (в большинстве случаев две).
  • В адресах $23C0-$23FF, $27C0-$27FF, $2BC0-$2BFF и $2FC0-$2FFF находятся атрибуты цвета для данной видеостраницы.
  • В адресах $3F00-$3F0F и $3F10-$3F1F содержатся числовые значения для выбора цвета из палитры для фона и для спрайтов отдельно.

Давайте ещё взглянем сюда:

Рис. 1. Память $2000-$23FF и её атрибуты $23C0-$23FF.

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

На рисунке область памяти, которая должна содержать номера спрайтов/тайлов. В данном случае, это просто адреса каждой ячейки памяти. Красным я выделил несколько ячеек памяти и внизу тоже выделил память. Внизу память с атрибутами для тех ячеек, что наверху (указано стрелкой). Точнее, две нижние строки атрибутов действуют на всю данную память 32х32 (да, всё верно, не 32х30, а 32х32 — на память включая память атрибутов).

Зачем всё это я показываю? Это нам поможет разобраться с тем, как передать всю информацию OpenGL для вывода конечного изображения.

▍ Подготавливаем текстуру


Я очень много раз переделывал даже простую подготовку текстуры. Изначально это было три текстуры, потом я сделал четыре текстуры (надеясь, что сумею вычесть текстуру из текстуры) и в конечном итоге остановился на одной. Так как оказалось, что рендер для Android не справляется с переключением текстур и это может занимать очень значительное время при рендере (нет, я не делаю чисто для Android, я просто тестировал, как будет работать эмулятор на нём).

Исходя из всего изученного, нам необходимо перевести видеопамять с адресов $0000-$0FFF и $1000-$1FFF в две текстуры. Из памяти берётся по 2 байта, например 0 и 8, и из них начинаем делать выборку (не ошибитесь в выборе байт — я изначально брал последовательно байты друг за другом и не мог понять, почему у меня текстура неправильная получается). Из этих двух байт надо делать выборку битов по одному (из каждого байта) слева направо. В моей реализации биты берутся справа налево.

На ассемблере по сути без разницы, но лучше справа налево, потому что некоторые ассемблеры не очень хорошо работают с байтами, а тут работа идёт именно с байтами.

При раскладе, что выбрал я, записывать данные надо не с нулевого знакоместа, а с 7-го (вообще, это удачно получилось, потому что OpenGL перевернёт эти данные в текстуре).

Создадим данные, куда будем записывать всё, что считали из «текстур» (из байт). В эти данные надо будет записывать 0 и 1. 0 — если прозрачно, 1 — если не прозрачно. Но вот только не 1 надо записывать, а $FFFFFFFF. Для RGBA цвета. Ну а нулями мы просто заполним данные изначально.

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

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

Прощу прощения, часть кода взята из ZenGL. Если что, смотрите исходники в нём, если что-то не понятно.
// Сначала пробуем сделать текстуры просто исходя из данных в памяти.
// Точнее на одной линии два значения одного спрайта (8 + 8).
// Спрайты будут лежать последовательно по 16 в ряд.
// Приходящее значение «data1» и «data2» — это память, которую надо
// перевести в текстуру. Всего 4096 + 4096 байт.
// Переводить будем в 2 текстуры (изначально 3 + 3).
function texCreateCHRROM(data1, data2: PByteArray): Boolean;
var
  i, j2, j3, n1, n2, x, y: Integer;
  ID: LongWord;
  dataOut: array [0..49153] of LongWord;      // 49154 * 4
  pdata: PLongWord;
  VRAMTex: zglPTexture;

  procedure MovData(data: PByteArray);
  var
    j: Integer;
  begin
    // обнуляем данные
    for j := 0 to 49153 do
    begin
      dataOut[j] := 0;               // fillbyte ?
    end;
    i := 4095;
    y := 0;

    while i >= 0 do                           // 4096
    begin
      x := 120;
      j3 := 16;                               // 16 спрайтов в линии
      while j3 > 0 do
      begin
        j2 := 8;                              // всего 8 линий в спрайте
        while j2 > 0 do
        begin
          n1 := data[i - 8];                  // брать надо два байта (составляют одну линию).
          n2 := data[i];
          // пробегаемся по данным в памяти
          for j := 7 downto 0 do
          begin
            // одна линия
            if ((n1 shr j) and 1) = 1 then    // удостовериться, что здесь именно байт браться будет.
            begin
              if ((n2 shr j) and 1) = 1 then
              begin
                // третья текстура
                dataOut[x + y * 128] := $FFFFFFFF;
              end
              else begin
                // первая текстура
                dataOut[32768 + x + y * 128] := $FFFFFFFF;
              end;
            end
            else
              if ((n2 shr j) and 1) = 1 then
              begin
                // вторая текстура
                dataOut[16384 + x + y * 128] := $FFFFFFFF;
              end;
            inc(x);
          end;

          x := x - 8;
          inc(y);                               // переходим к следующей линии
          dec(j2);                              // указываем, что уменьшилось количество линий в спрайте.
          dec(i);                               // смещаем
        end;
        dec(j3);
        dec(y, 8);                              // вернуть на начальную линию
        dec(x, 8);                              // сдвинуть икс на следующий спрайт
        dec(i, 8);                              // здесь смещение через спрайт (обработали уже два спрайта).
      end;
      y := y + 8;                               // если 16 спрайтов готово, то перейти на следующие спрайты
    end;
  end;

begin
  // возвращаем «ID»
  Result := False;

  pdata := @dataOut;
  MovData(data1);

  SetLength(managerVRAM.VRAMTexture, managerVRAM.Count + 1);

    ID := tex_Add();                       // это номер в менеджере текстур.

    VRAMTex := managerTexture.Texture[ID];
    VRAMTex^.Width  := 128;                // один спрайт по 8, в строке 16 спрайтов.
    VRAMTex^.Height := 384;                // один спрайт по 8, в столбце 48 спрайтов.
    VRAMTex^.Format := TEX_FORMAT_RGBA;
    VRAMTex^.Flags  := TEX_DEFAULT_2D;
    tex_CalcFlags(VRAMTex^, PByteArray(pdata));

    tex_CalcTexCoords(ID);
    if not tex_CreateGL(ID, PByteArray(pdata)) Then
    begin
      tex_Del(ID);
      VRAMTex^.Flags := 0;                 // обнуляем, это будет приравнено к удалению текстуры (но на самом деле не должно быть этого, это ошибка, и приложение не должно дальше работать).
    end;
    // ID текстуры можно получить только после создания текстуры на видеокарте.
    managerVRAM.vRAMtexture[managerVRAM.Count][0] := ID;

  MovData(data2);

    ID := tex_Add();                       // это номер в менеджере текстур.
    VRAMTex := managerTexture.Texture[ID];
    VRAMTex^.Width  := 128;                // один спрайт по 8, в строке 16 спрайтов.
    VRAMTex^.Height := 384;                // один спрайт по 8, в столбце 48 спрайтов.
    VRAMTex^.Format := TEX_FORMAT_RGBA;
    VRAMTex^.Flags  := TEX_DEFAULT_2D;
    tex_CalcFlags(VRAMTex^, PByteArray(pdata));

    tex_CalcTexCoords(ID);
    if not tex_CreateGL(ID, PByteArray(pdata)) Then
    begin
      tex_Del(ID);
      VRAMTex^.Flags := 0;                 // обнуляем, это будет приравнено к удалению текстуры (но на самом деле не должно быть этого, это ошибка, и приложение не должно дальше работать).
    end;
    // ID текстуры можно получить только после создания текстуры на видеокарте.
    managerVRAM.vRAMtexture[managerVRAM.Count][1] := ID;

  inc(managerVRAM.Count);
end;


Наши текстуры готовы. И да, мы получили их две. Одна для адресов $0000-$FFF, вторая для адресов $1000-$1FFF. И вы уже можете попробовать их вывести, если реализовали подобный код.

▍ Подготавливаем вывод спрайтов/тайлов


Можно бы было сразу переходить к рендеру, но для того, чтобы вывести нашу текстуру, нужно разделить её и наложить три части друг на друга, чтобы получить спрайт/тайл. Для разбиения текстуры мы создадим процедуру, которая «поделит нашу текстуру» и сохранит текстурные координаты.

Получаем текстурные координаты.
procedure Texture0_SetFrameSize;
var
  i: Integer;
  tX, tY, u, v, tXsubU, tYsubV: Single;
begin
  // Разбиваем текстуру на подтекстуры 8х8, дальше нам нужны будут только координаты фреймов из этой текстуры, для вывода.
  // Спрайты пойдут слева направо и сверху вниз (если я правильно помню).
  u := 1 / 16;
  v := 1 / 48;

  Texture0FrameCoord[0, 0].X := 0;
  Texture0FrameCoord[0, 0].Y := 1;
  Texture0FrameCoord[0, 1].X := 1;
  Texture0FrameCoord[0, 1].Y := 1;
  Texture0FrameCoord[0, 2].X := 1;
  Texture0FrameCoord[0, 2].Y := 0;
  Texture0FrameCoord[0, 3].X := 0;
  Texture0FrameCoord[0, 3].Y := 0;

  for i := 1 to 768 do
  begin
    tY := i div 16;
    tX := i — tY * 16;
    tY := 48 - tY;
    if tX = 0 Then
    begin
      tX := 16;
      tY := tY + 1;
    end;
    tX := tX * u;
    tY := tY * v;
    tXsubU := tx - u;
    tYsubV := tY - v;

    Texture0FrameCoord[i, 0].X := tXsubU;
    Texture0FrameCoord[i, 0].Y := tY;

    Texture0FrameCoord[i, 1].X := tX;
    Texture0FrameCoord[i, 1].Y := tY;

    Texture0FrameCoord[i, 2].X := tX;
    Texture0FrameCoord[i, 2].Y := tYsubV;

    Texture0FrameCoord[i, 3].X := tXsubU;
    Texture0FrameCoord[i, 3].Y := tYsubV;
  end;
end;


Но это только часть дела. Так как наши спрайты/тайлы состоят из трёх текстур, значит, их надо выводить три раза? Ну можно три раза? Давайте сделаем немного проще, выведем целостный спрайт сразу, за раз. Координаты у спрайта не меняются. Смещаются текстурные коодинаты, но только по вертикали. Ну и остаётся вычислить цвет. Для спрайта вычислить цвет не проблема, он лежит в данных для спрайта (ну да, там два бита лежит, вторые два бита в «текстуре» заложены). А вот для тайла придётся вычислять… Кстати, для этого как раз и пригодится Рисунок 1.

А пока реализуем процедуру вывода спрайта/тайла.

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

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

Вывод тройного спрайта.
procedure triple_texture_Draw(IDTex, numPal: LongWord; Frame: Word; const rect: zglTRect2D; dataAtrib: LongWord; var cut: Integer; FX: LongWord = FX_BLEND);
var
  quad: array[0..3] of zglTPoint2D;
  tc  : zglTTextureCoord;
  tci : zglPTexCoordIndex;
  fc  : Integer;
  n, one, two: Single;

  numColor, addr: LongWord;

  procedure add_data(add: {$IfDef CPU64}Int64{$Else}Integer{$EndIf});
  var
    ii, iii: {$IfDef CPU64}Int64{$Else}Integer{$EndIf};
  begin
    for ii := 0 to 2 do
    begin
      VertColor[ii + add] := zglPColor(@Palette[numColor])^;
      Vertices[ii + add].U := zglPPoint2D(@tc[tci[ii]]).X;
      Vertices[ii + add].V := zglPPoint2D(@tc[tci[ii]]).Y;
      Vertices[ii + add].X := quad[ii].X;
      Vertices[ii + add].Y := quad[ii].Y;
    end;
    ii := 3 + add;
    iii := ii - 1;
    VertColor[ii] := zglPColor(@Palette[numColor])^;
    Vertices[ii].U := Vertices[iii].U;
    Vertices[ii].V := Vertices[iii].V;
    Vertices[ii].X := Vertices[iii].X;
    Vertices[ii].Y := Vertices[iii].Y;

    inc(ii);
    VertColor[ii] := zglPColor(@Palette[numColor])^;
    Vertices[ii].U := zglPPoint2D(@tc[tci[3]]).X;
    Vertices[ii].V := zglPPoint2D(@tc[tci[3]]).Y;
    Vertices[ii].X := quad[3].X;
    Vertices[ii].Y := quad[3].Y;

    inc(ii);
    VertColor[ii] := zglPColor(@Palette[numColor])^;
    Vertices[ii].U := Vertices[add].U;
    Vertices[ii].V := Vertices[add].V;
    Vertices[ii].X := Vertices[add].X;
    Vertices[ii].Y := Vertices[add].Y;
  end;

begin
  fc := 256;
  if Frame > fc Then
    DEC(Frame, ((Frame - 1) div fc) * fc)
  else
    if Frame < 1 Then
      INC(Frame, (abs(Frame) div fc + 1) * fc);
  // отражение текстурных координат
  tci := @FLIP_TEXCOORD[FX and FX2D_FLIPX + FX and FX2D_FLIPY];
  tc  := Texture0FrameCoord[Frame];
  if cut > 0 then
  begin
    if cut >= 8 then
      exit
    else begin
      n := rect.W / 8;
      // Координата обрезания должна оставаться на месте.
      quad[0].X := rect.X + n * cut;
      if (FX and FX2D_FLIPX) > 0 then
        cut := 8 - cut;
      // Обрезаться будут только текстурные координаты, в зависимости от зеркальности.
      n := rect.X + n * cut;
      quad[1].X := rect.X + rect.W;
      quad[0].Y := rect.Y;
      quad[2].Y := rect.Y + rect.H;
      n := zglTPoint2D(tc[0]).X + (zglTPoint2D(tc[1]).X - zglTPoint2D(tc[0]).X) / 8 * cut;
      if (FX and FX2D_FLIPX) > 0 then
      begin
        zglTPoint2D(tc[1]).X := n;
        zglTPoint2D(tc[2]).X := n;
      end
      else begin
        zglTPoint2D(tc[0]).X := n;
        zglTPoint2D(tc[3]).X := n;
      end;
    end;
  end
  else begin
    quad[0].X := rect.X;
    quad[1].X := rect.X + rect.W;
    quad[0].Y := rect.Y;
    quad[2].Y := rect.Y + rect.H;
  end;
  quad[1].Y := quad[0].Y;
  quad[2].X := quad[1].X;
  quad[3].X := quad[0].X;
  quad[3].Y := quad[2].Y;

  // Теперь это тоже можно вынести за пределы процедуры?
  glBindTexture(GL_TEXTURE_2D, IDTex);

  // Цвет из памяти $3F00 + атрибут + 1
  addr := $1F00 + dataAtrib + 1 + numPal;
  numColor := videoRAM[addr] and $3F;
  add_data(0);

  // Смещаем на следующую часть текстуры.
  inc(Frame, 256);
  tc  := Texture0FrameCoord[Frame];
  if cut > 0 then
    if (FX and FX2D_FLIPX) > 0 then
    begin
      zglTPoint2D(tc[1]).X := n;
      zglTPoint2D(tc[2]).X := n;
    end
    else begin
      zglTPoint2D(tc[0]).X := n;
      zglTPoint2D(tc[3]).X := n;
    end;
  // Цвет из памяти + атрибут + 2
  numColor := videoRAM[addr + 1] and $3F;
  add_data(6);

  // Смещаем на следующую часть текстуры.
  inc(Frame, 256);
  tc  := Texture0FrameCoord[Frame];
  if cut > 0 then
    if (FX and FX2D_FLIPX) > 0 then
    begin
      zglTPoint2D(tc[1]).X := n;
      zglTPoint2D(tc[2]).X := n;
    end
    else begin
      zglTPoint2D(tc[0]).X := n;
      zglTPoint2D(tc[3]).X := n;
    end;
  // Цвет из памяти + атрибут + 3
  numColor := videoRAM[addr + 2] and $3F;
  add_data(12);

  glEnableClientState(GL_TEXTURE_COORD_ARRAY);
  glTexCoordPointer(2, GL_FLOAT, 16, @Vertices[0].U);
  glEnableClientState(GL_COLOR_ARRAY);
  glColorPointer(4, GL_FLOAT, 0, @VertColor[0]);

  glEnableClientState(GL_VERTEX_ARRAY);
  glVertexPointer(2, GL_FLOAT, 16, @Vertices[0].X);

  glDrawArrays(GL_TRIANGLES, 0, 18);

  glDisableClientState(GL_VERTEX_ARRAY);
  glDisableClientState(GL_COLOR_ARRAY);
  glDisableClientState(GL_TEXTURE_COORD_ARRAY);
end;


Основная часть для вывода готова, но до полной реализации ещё далеко. Надо ещё всё вывести на экран.

▍ Основной вывод


Не буду томить — сразу выложу код, а уже дальше буду разбирать его.

Вывод фона, спрайтов и тайлов.
procedure FrameDraw;
var
  i, j, m1, m2, m3, z, _x, _y, yloop, xloop, _cut: Integer;
  _rect, rect2, rect3 ,rect4: zglTRect2D;
  n, _XScroll, _bank: {$IfDef CPU64}QWord{$Else}LongWord{$EndIf};

  procedure CalcColor(num: LongWord);
  begin
    case j of
        // С "нулевой" страницы мы снимаем байт атрибутов из памяти. Из них надо выбрать 2 бита.
        $000..$003, $020..$023, $040..$043, $060..$063: n := videoMem[num]^[$3C0];         // 7C0, BC0, FC0
        $004..$007, $024..$027, $044..$047, $064..$067: n := videoMem[num]^[$3C1];
        $008..$00B, $028..$02B, $048..$04B, $068..$06B: n := videoMem[num]^[$3C2];         // ???
        $00C..$00F, $02C..$02F, $04C..$04F, $06C..$06F: n := videoMem[num]^[$3C3];
        $010..$013, $030..$033, $050..$053, $070..$073: n := videoMem[num]^[$3C4];
        $014..$017, $034..$037, $054..$057, $074..$077: n := videoMem[num]^[$3C5];
        $018..$01B, $038..$03B, $058..$05B, $078..$07B: n := videoMem[num]^[$3C6];
        $01C..$01F, $03C..$03F, $05C..$05F, $07C..$07F: n := videoMem[num]^[$3C7];

        $080..$083, $0A0..$0A3, $0C0..$0C3, $0E0..$0E3: n := videoMem[num]^[$3C8];
        $084..$087, $0A4..$0A7, $0C4..$0C7, $0E4..$0E7: n := videoMem[num]^[$3C9];
        $088..$08B, $0A8..$0AB, $0C8..$0CB, $0E8..$0EB: n := videoMem[num]^[$3CA];
        $08C..$08F, $0AC..$0AF, $0CC..$0CF, $0EC..$0EF: n := videoMem[num]^[$3CB];
        $090..$093, $0B0..$0B3, $0D0..$0D3, $0F0..$0F3: n := videoMem[num]^[$3CC];
        $094..$097, $0B4..$0B7, $0D4..$0D7, $0F4..$0F7: n := videoMem[num]^[$3CD];
        $098..$09B, $0B8..$0BB, $0D8..$0DB, $0F8..$0FB: n := videoMem[num]^[$3CE];
        $09C..$09F, $0BC..$0BF, $0DC..$0DF, $0FC..$0FF: n := videoMem[num]^[$3CF];

        $100..$103, $120..$123, $140..$143, $160..$163: n := videoMem[num]^[$3D0];
        $104..$107, $124..$127, $144..$147, $164..$167: n := videoMem[num]^[$3D1];
        $108..$10B, $128..$12B, $148..$14B, $168..$16B: n := videoMem[num]^[$3D2];
        $10C..$10F, $12C..$12F, $14C..$14F, $16C..$16F: n := videoMem[num]^[$3D3];
        $110..$113, $130..$133, $150..$153, $170..$173: n := videoMem[num]^[$3D4];
        $114..$117, $134..$137, $154..$157, $174..$177: n := videoMem[num]^[$3D5];
        $118..$11B, $138..$13B, $158..$15B, $178..$17B: n := videoMem[num]^[$3D6];
        $11C..$11F, $13C..$13F, $15C..$15F, $17C..$17F: n := videoMem[num]^[$3D7];

        $180..$183, $1A0..$1A3, $1C0..$1C3, $1E0..$1E3: n := videoMem[num]^[$3D8];
        $184..$187, $1A4..$1A7, $1C4..$1C7, $1E4..$1E7: n := videoMem[num]^[$3D9];
        $188..$18B, $1A8..$1AB, $1C8..$1CB, $1E8..$1EB: n := videoMem[num]^[$3DA];
        $18C..$18F, $1AC..$1AF, $1CC..$1CF, $1EC..$1EF: n := videoMem[num]^[$3DB];
        $190..$193, $1B0..$1B3, $1D0..$1D3, $1F0..$1F3: n := videoMem[num]^[$3DC];
        $194..$197, $1B4..$1B7, $1D4..$1D7, $1F4..$1F7: n := videoMem[num]^[$3DD];
        $198..$19B, $1B8..$1BB, $1D8..$1DB, $1F8..$1FB: n := videoMem[num]^[$3DE];
        $19C..$19F, $1BC..$1BF, $1DC..$1DF, $1FC..$1FF: n := videoMem[num]^[$3DF];

        $200..$203, $220..$223, $240..$243, $260..$263: n := videoMem[num]^[$3E0];
        $204..$207, $224..$227, $244..$247, $264..$267: n := videoMem[num]^[$3E1];
        $208..$20B, $228..$22B, $248..$24B, $268..$26B: n := videoMem[num]^[$3E2];
        $20C..$20F, $22C..$22F, $24C..$24F, $26C..$26F: n := videoMem[num]^[$3E3];
        $210..$213, $230..$233, $250..$253, $270..$273: n := videoMem[num]^[$3E4];
        $214..$217, $234..$237, $254..$257, $274..$277: n := videoMem[num]^[$3E5];
        $218..$21B, $238..$23B, $258..$25B, $278..$27B: n := videoMem[num]^[$3E6];
        $21C..$21F, $23C..$23F, $25C..$25F, $27C..$27F: n := videoMem[num]^[$3E7];

        $280..$283, $2A0..$2A3, $2C0..$2C3, $2E0..$2E3: n := videoMem[num]^[$3E8];
        $284..$287, $2A4..$2A7, $2C4..$2C7, $2E4..$2E7: n := videoMem[num]^[$3E9];
        $288..$28B, $2A8..$2AB, $2C8..$2CB, $2E8..$2EB: n := videoMem[num]^[$3EA];
        $28C..$28F, $2AC..$2AF, $2CC..$2CF, $2EC..$2EF: n := videoMem[num]^[$3EB];
        $290..$293, $2B0..$2B3, $2D0..$2D3, $2F0..$2F3: n := videoMem[num]^[$3EC];
        $294..$297, $2B4..$2B7, $2D4..$2D7, $2F4..$2F7: n := videoMem[num]^[$3ED];
        $298..$29B, $2B8..$2BB, $2D8..$2DB, $2F8..$2FB: n := videoMem[num]^[$3EE];
        $29C..$29F, $2BC..$2BF, $2DC..$2DF, $2FC..$2FF: n := videoMem[num]^[$3EF];

        $300..$303, $320..$323, $340..$343, $360..$363: n := videoMem[num]^[$3F0];
        $304..$307, $324..$327, $344..$347, $364..$367: n := videoMem[num]^[$3F1];
        $308..$30B, $328..$32B, $348..$34B, $368..$36B: n := videoMem[num]^[$3F2];
        $30C..$30F, $32C..$32F, $34C..$34F, $36C..$36F: n := videoMem[num]^[$3F3];
        $310..$313, $330..$333, $350..$353, $370..$373: n := videoMem[num]^[$3F4];
        $314..$317, $334..$337, $354..$357, $374..$377: n := videoMem[num]^[$3F5];
        $318..$31B, $338..$33B, $358..$35B, $378..$37B: n := videoMem[num]^[$3F6];
        $31C..$31F, $33C..$33F, $35C..$35F, $37C..$37F: n := videoMem[num]^[$3F7];

          //определиться что делать с этими адресами. Это только тот момент, когда принудительно выставили данный адрес.
        $380..$383, $3A0..$3A3, $3C0..$3C3, $3E0..$3E3: n := videoMem[num]^[$3F8];
        $384..$387, $3A4..$3A7, $3C4..$3C7, $3E4..$3E7: n := videoMem[num]^[$3F9];
        $388..$38B, $3A8..$3AB, $3C8..$3CB, $3E8..$3EB: n := videoMem[num]^[$3FA];
        $38C..$38F, $3AC..$3AF, $3CC..$3CF, $3EC..$3EF: n := videoMem[num]^[$3FB];
        $390..$393, $3B0..$3B3, $3D0..$3D3, $3F0..$3F3: n := videoMem[num]^[$3FC];
        $394..$397, $3B4..$3B7, $3D4..$3D7, $3F4..$3F7: n := videoMem[num]^[$3FD];
        $398..$39B, $3B8..$3BB, $3D8..$3DB, $3F8..$3FB: n := videoMem[num]^[$3FE];
        $39C..$39F, $3BC..$3BF, $3DC..$3DF, $3FC..$3FF: n := videoMem[num]^[$3FF];
      end;

      // Здесть происходит выботка битов                                               // Биты 0, 1 - левый верхний ($03)     биты 2, 3 - правый верхний ($0С)                7 6 | 5 4 | 3 2 | 1 0
      // Бит 2 - это четыре правых адреса (биты 2, 3 или 4, 5)                         // Биты 4, 5 - левый нижний ($C0)      Биты 6, 7 - правый нижний ($30)
      // Бит 6 - это 4 нижних адреса (биты 4, 5 или 6, 7)
      if (j and 2) > 0 then
      begin                                // справа
        if (j and $40) > 0 then
          n := (n and $C0) shr 4             // снизу
        else
          n := n and $0C;                      // сверху
      end
      else begin                           // слева
        if (j and $40) > 0 then
          n := (n and $30) shr 2               // снизу
        else
          n := (n and $03) shl 2;              // сверху
      end;
  end;

  procedure CheckBanks(data: LongWord);
  begin
    // В общем, пока делаю, считая, что запись в видеопамять происходит последовательно. Если это будет вызывать ошибку — значит, то же самое надо будет делать и для кода.
    // bank[0] - левый верхний, bank[1] - правый верхний.
    // bank[2] - левый нижний,  bank[3] - правый нижний.
    with NesPPU do
    begin
      if (NesCPU.RomInfo.Mirroring and MIRROR_VERTICAL) > 0 then
      begin
        // Ограничиваем двумя банками, если будут использоваться все 4, то это должно быть по-другому.
        // здесь 0 и 1.
        bank[0] := data and 1; // and 3;
        bank[2] := bank[0];
        bank[1] := 1 - bank[0];
        bank[3] := bank[1];
      end
      else begin
          // здесь 0 и 2.
          bank[0] := data and 2; // and 3;
          bank[1] := bank[0];
          bank[2] := 2 - bank[0];
          bank[3] := bank[2];
        end;
      end;
    end;
  end;

  procedure DrawSprite;
  begin
    _rect.X := spriteMem[j * 4 + 3];
    n := spriteMem[j * 4 + 1];                  // Текущий "кадр" (номер спрайта).
    m2 := byte((m1 and $80) > 0) * FX2D_FLIPY;
    m3 := byte((m1 and $40) > 0) * FX2D_FLIPX;
    // тут надо обрезать текстуру
    if ((NesPPU.dataPPU._ppumask and MASK_SPRITEPIXELS) = 0) and (_rect.X < 8) then
      _cut := 8 - Round(_rect.X)
    else
      _cut := 0;
    if (Memory[1, PPUCTRL and $1FFF] and SPRITE_LARGE) = 0 then
      triple_texture_Draw(NesPPU.spriteRom, $10, n + 1, _rect, (m1 and 3) shl 2, _cut, FX_BLEND or m2 or m3)
    else begin
      rect2.X := _rect.X;
      rect2.Y := _rect.Y + 8;
      i := $10;
      _x := (n and 1);                // Если 1, то получим 1-ю текстуру
      n := n and $FE;
      if ((m1 and $80) = 0) then
      begin
        triple_texture_Draw(NesPPU.bankTex[_x], i, n + 1, _rect, (m1 and 3) shl 2, _cut, m2 or m3);
        triple_texture_Draw(NesPPU.bankTex[_x], i, n + 2, rect2, (m1 and 3) shl 2, _cut, m2 or m3);
      end
      else begin
        triple_texture_Draw(NesPPU.bankTex[_x], i, n + 1, rect2, (m1 and 3) shl 2, _cut, m2 or m3);
        triple_texture_Draw(NesPPU.bankTex[_x], i, n + 2, _rect, (m1 and 3) shl 2, _cut, m2 or m3);
      end;
    end;
  end;

begin
  pr2d_Rect(NesPPU.Screen, backNesColor, PR2D_FILL);                              // Фон должен прорисовываться "всегда".

  if (NesPPU.PPUflags and NEW_DATA_READY) > 0 then
  begin
    texCreateCHRROM(PByteArray(@videoCIRAM[pageVMem, 0]), PByteArray(@videoCIRAM[pageVMem, $1000]));
    NesPPU.PPUflags := NesPPU.PPUflags and ($FFFFFFFF - NO_CREATE_TEXTURE);
    dec(managerVRAM.Count);
  end;

  if (NesPPU.PPUflags and NO_CREATE_TEXTURE) > 0 then
    exit;
  if ((NesPPU.dataPPU._ppumask and (MASK_SPRITE or MASK_BACKGROUND)) = 0) then
    exit;

  z := 8;
  _rect.W := z;
  _rect.H := z;          // Высота спрайтов может меняться
  rect2.H := z;
  rect2.W := z;
  rect3.X := 0;          rect3.W := 256;              // Для ширины правильно (32 * z).
  rect3.H := z;

  rect4.X := 0;          rect4.W := 256;
  rect4.Y := 0;          rect4.H := 240;

  // Прорисовываем два таргета для спрайтов.
  if ((NesPPU.dataPPU._ppumask and MASK_SPRITE) = 0) then
  begin
    rtarget_Set(pzTarTex0);
    //pr2d_Rect(NesPPU.Screen, backClearColor);
    rtarget_Set(RT_NO_TARGET);
    rtarget_Set(pzTarTex2);
    //pr2d_Rect(NesPPU.Screen, backClearColor);
    rtarget_Set(RT_NO_TARGET);
  end
  else begin
    // Прорисовываем спрайты "за фоном". От последнего к нулевому.
    rtarget_Set(pzTarTex0);
    for j := 63 downto 0 do
    begin
      glEnable(GL_BLEND);
      glEnable(GL_TEXTURE_2D);
      m1 := spriteMem[j * 4 + 2];
      if ((m1 and $20) > 0) then
      begin
        // Написано, что надо вычесть 1 из Y, но это для стандартного решения и оно неверное в данном случае.
        _rect.Y := spriteMem[j * 4] + 1;   // + 1 это лишь догадка
        if _rect.Y > 240 then
          Continue;
        DrawSprite;
      end;
      glDisable(GL_TEXTURE_2D);
      glDisable(GL_BLEND);
    end;
    rtarget_Set(RT_NO_TARGET);

    rtarget_Set(pzTarTex2);
    // И снова прорисовываем спрайты уже на переднем плане. От последнего к нулевому.
    for j := 63 downto 0 do
    begin
      glEnable(GL_BLEND);
      glEnable(GL_TEXTURE_2D);
      m1 := spriteMem[j * 4 + 2];
      if ((m1 and $20) = 0) then
      begin
        _rect.Y := spriteMem[j * 4] + 1;   // + 1 это лишь догадка
        if _rect.Y > 240 then
          Continue;
        DrawSprite;
      end;
      glDisable(GL_TEXTURE_2D);
      glDisable(GL_BLEND);
    end;
    rtarget_Set(RT_NO_TARGET);
  end;

  if ((NesPPU.dataPPU._ppumask and MASK_BACKGROUND) = 0) then
  begin
    rtarget_Set(pzTarTex1);
    //pr2d_Rect(NesPPU.Screen, backClearColor);
    rtarget_Set(RT_NO_TARGET);
  end
  else begin
    rtarget_Set(pzTarTex1);
    // Приходит полный PPUCTRL.
    CheckBanks(NesPPU.dataPPU._ppuctrl.data[0] and 3);
    m1 := 0;
    m2 := 0;
    _XScroll := NesPPU.dataPPU.XScroll.data[m2];
    glEnable(GL_BLEND);
    glEnable(GL_TEXTURE_2D);
    for yloop := -1 to 30 do
    begin
      if m1 < NesPPU.dataPPU._ppuctrl.count then
      begin
        if yloop = NesPPU.dataPPU._ppuctrl.line[m1 + 1] then
        begin
          Inc(m1);
          CheckBanks(NesPPU.dataPPU._ppuctrl.data[m1] and 3);
        end;
      end;
      // Это для проверки смещения
      if m2 < NesPPU.dataPPU.XScroll.count then
        if yloop = NesPPU.dataPPU.XScroll.line[m2 + 1] then
        begin
          inc(m2);
          _XScroll := NesPPU.dataPPU.XScroll.data[m2];
        end;

      _y := yloop * z + NesPPU.YScroll;
      for xloop := -1 to 32 do
      begin
        _rect.X := xloop * z;
        _rect.Y := yloop * z;
        _x := xloop * z + _XScroll;

        if _x < 256 then
        begin
          if _y < 240 then              // "A" - blank
          begin
            i := 0;
          end
          else begin                    // "B" or "C" - blank
            _y := _y - 240;
            i := 2;
          end;
        end
        else begin
          _x := _x - 256;
          if _y < 240 then              // "C" or "B" - blank
            i := 1
          else begin                    // "D" - blank
            _y := _y - 240;
            i := 3;
          end;
        end;

        // Заглушка.
        if _y < 0 then
          _y := _y + 240;
        if _x < 0 then
          _x := _x + 256;
        j := Trunc(_y / 8) * 32 + trunc(_x / 8);
        if j > 959 then
          j := j - 960;
        _rect.X := _rect.X - _x and 7;
        _rect.Y := _rect.Y - _y and 7;
        // если x < -8 или x > 256, то не надо ничего делать.
        // если y < -8 или y > 240, то не надо ничего делать.
        if (_rect.x < -7) or (_rect.X > 255) then
          Continue;
        if (_rect.Y < -7) or (_rect.Y > 239) then
          Continue;

        _bank := NesPPU.bank[i];
        CalcColor(_bank);
        if ((NesPPU.dataPPU._ppumask and MASK_BACKPIXELS) = 0) and (_rect.X < 8) then
          _cut := 8 - Round(_rect.X)
        else
          _cut := 0;
        // Итак, для вывода нужен "задний фон" (банк), указатель палитры (0), фрейм, координаты и "остаток" от палитры (n).
        triple_texture_Draw(NesPPU.backgroundRom, 0, videoMem[_bank]^[j] + 1, _rect, n, _cut);
      end;
    end;
    glDisable(GL_TEXTURE_2D);
    glDisable(GL_BLEND);

    { Это сетка на экран
    for i := 0 to 31 do
      for j := 0 to 29 do
      begin
        pr2d_Line(i * 8, j * 8, i * 8, j * 8 + 128, cl_White05);
        pr2d_Line(i * 8, j * 8, i * 8 + 128, j * 8, cl_White05);
      end;                     }

    rtarget_Set(RT_NO_TARGET);
  end;

  rect2.X := 1;                 rect2.Y := 1;
  rect2.W := 512;               rect2.H := 480;
  ssprite2d_Draw(rtarget_GetSurface(pzTarTex0), rect2, 0);
  ssprite2d_Draw(rtarget_GetSurface(pzTarTex1), rect2, 0);
  ssprite2d_Draw(rtarget_GetSurface(pzTarTex2), rect2, 0);
end;


И сразу приложу ещё одну картинку:

Рис. 2. Выборка битов

Теперь рассмотрим код, Рисунок 1 и Рисунок 2. Как я и говорил ранее, для спрайтов нет проблемы выделить цвет. Он указан в третьем байте, в битах 0 и 1.

Сложность возникает при выборке цвета для тайлов (кстати, в Nes тайлов почти всегда больше на экране, чем спрайтов). В процедуре CalcColor мы производим эти вычисления на основании адресов из знакогенераторов и их атрибутов. На Рисунке 1 вы можете посмотреть адреса первого знакогенератора и его атрибутов. Выборка в коде реализована согласно этих адресов при изменении экранной страницы, в коде надо просто переключиться на нужный банк памяти.

Изначально я делал выборку посредством If, но давайте будем честны, подобная выборка дорога в реализации в большинстве случаев. Ведь надо сделать выборку из 960 тайлов (позже был реализован вывод более 1000 тайлов, для скроллируемого окна).

Выборку для байта я сделал, но реализовать выборку для битов из байта было сложнее. Адресов много, и как сделать правильную выборку значений? Ответ дал мне второй рисунок. Я побитово исследовал некоторые адреса, где увидел закономерность (на рисунке она уже указана, да и на рисунке красными стрелками показаны биты 6, а коричневыми биты 2). И что же я увидел на этом рисунке? Какие-то два бита: 2 и 6. И привязал их к чему-то…

На рисунке 2 присутствует ошибка по тому, за какую часть отвечает бит 2.

image
Рис. 3. Разделение атрибутов по адресам

image
Рис. 4. Разделение атрибутов по битам.

Один байт цвета привязан к 4 областям по 4 адреса (рис. 1 и рис. 3, выделил красным и разбил на 4 части). И если вы изучили всю тему по PPU, то поймёте, что это означает цвет для определённой области (4 адресов для тайлов). Сопостовляя все области (взяв любую из них), можно увидеть, что биты 2 и 6 закономерно изменяются, согласно всех взятых адресов. Для примера возьмём первые адреса: $2000-$2003, $2020-$2023, $2040-$2043 и $2060-$2063. Когда выборка идёт из левых адресов ($2000, $2001, $2020, $2021, $2040, $2041, $2060 и $2061), то бит 2 всегда = 0. А когда выборка идёт из правых адресов ($2002, $2003, $2022, $2023, $2042, $2043, $2062 и $2063), то бит 2 всегда = 1. Когда выборка идёт из верхних адресов ($2000-$2003 и $2020-$2023), то бит 6 всегра = 0. А когда выборка идёт из нижних адресов ($2040-$2043 и $2060-$2063), то бит 6 всегда = 1.

Я специально выдернул информацию с сайта Мигера (рис. 3 и рис. 4), чтобы было всё нагляднее, и там правильное расположение указано (вроде как). Рассматривая Рисунок 3 и Рисунок 4, можно увидеть, что найденный бит 2 (если он равен единице) означает выборку битов из байта атрибутов 2-3 или 4-5, а найденный бит 6 (если он равен единице) означает выборку битов из байта атрибутов 4-5 или 6-7.

В конце процедуры CalcColor производятся вычисления n согласно полученным данным. Этим значением и будут два верхних бита для цвета — 2-й и 3-й (именно они влияют на цвет в процедуре triple_texture_Draw, не считая выбираемых текстур, где каждая текстура на 1 увеличивает адрес цвета).

Вот теперь, по сути, сам вывод PPU готов (но сам PPU ещё нет, для его правильной работы нужны ещё согласования с процессором).

Очередной текст, который можно пропустить.
Изначально рендер PPU был намного проще. Я его реализовывал посредством вывода 960 тайлов, просто высчитывая координаты для них. Это было не неправильно, но с таким подходом сложно было реализовать скроллинг, терялись некоторые части то с одной стороны экрана, то с другой. А также невозможно было предугадать в какой позиции будет прорисовываться данный тайл (позиция тайлов была «плавающей», если в игре присутствует скроллинг, а это большинство игр). Производились вычисления значений X и Y для данного тайла, и, согласно полученным координатам, он выводился — это очень сильно мешало сделать правильный скроллинг экрана, потому что, как позже выяснилось, по горизонтали изображение могло по-разному скроллироваться от одной линии знакомест до другой (8×256 бит, 30 строчек по 8 бит). Уже позже пришлось делать расчёты от конкретного положения на экране (какой тайл попадает в данные координаты или какие тайлы попадают), что сделало возможным полностью плавный скроллинг, но добавило своих мелких проблем.

Вообще, некоторые решения в Nes меня сбивали с толку и я сам себя запутывал, надумывая какие-то ужастики у себя в голове. То, чего оказывается не было вообще. Но новая тропа, она вот такая. Не знаешь где можешь оступиться и как потом придётся возвращаться обратно на тропу. Хотя и данных полно уже исследованных, а кажется, что по неизведанной тропе идёшь. )))

▍ Согласование CPU и PPU


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

Изначальную проверку я сделал очень просто. Я находил местоположение нулевого спрайта и по правому верхнему углу проверял столкновение. Да, это было не совсем правильно, но уже в некоторых играх это исправило ошибочное поведение. Позже я реализовал более точную коллизию, которая учитывает столкновение по Y (если верхние ряды спрайтов пустые, то он доходит до первого не пустого ряда). Это тоже не совсем правильно, так как в данном случае не учитывается, что находится на фоне (произошло ли столкновение и верное ли оно было?).

// Это "грубый" подсчёт.
  // Циклы уменьшаются, потому надо расчитать столкновение и вычесть значение из полной части циклов.
  // Также надо учесть зеркальность спрайта по Y (не забыть сделать!).
  j := spriteMem[1];
  m := j * 16 + ($1000 * byte((Memory[1, PPUCTRL and $1FFF] and SPRITE_CHR_ROM) > 1));
  for i := 0 to 7 do
  begin
    n1 := videoCIRAM[pageVMem, m];
    n2 := videoCIRAM[pageVMem, m + 8];
    inc(m);
    if (n1 = 0) and (n2 = 0) then
      Continue
    else
      Break;
  end;
  collizionZero := 29781 - Byte(zeroTakt) - round(((spriteMem[0] + i) * 341 + spriteMem[3]) / 3);

А также надо проверять столкновение в цикле обработки инструкций CPU (мне очень «нравится» данный код, но, к сожалению, проверок тут приходится «лепить» очень много).

  with NesPPU do
  begin
    // Было столкновение?
    // Memory[PPUMASK] and 2  = $02;  левый столбец виден/ не виден    фон
    // Memory[PPUMASK] and 4  = $04;  левый столбец виден/ не виден    спрайт
    // Memory[PPUMASK] and 8  = $08;  фон виден/не виден
    // Memory[PPUMASK] and 16 = $10; спрайты видны/не видны
    if (_nesCPU.cycles <= _nesCPU.collizionZero) then
      if ((_nesCPU.stateFlag and PPU_STATUS_SPRITE) = 0) then
        if (_nesCPU.collizionZero >= 0) then
          // Если и спрайты и фон видны
          if ((Memory[1, PPUMASK and $1FFF] and $10) > 0) and ((Memory[1, PPUMASK and $1FFF] and $8) > 0) then
            // Если в правой части
            if ((Memory[1, PPUMASK and $1FFF] and $4) > 0) and ((Memory[1, PPUMASK and $1FFF] and $2) > 0) then
            begin
              if spriteMem[3] < 255 then
              begin
                _nesCPU.stateFlag := _nesCPU.stateFlag or PPU_STATUS_SPRITE;
                Memory[1, PPUSTATUS and $1FFF] := Memory[1, PPUSTATUS and $1FFF] or $40;
              end;
            end
            else begin
              if (spriteMem[3] > 7) and (spriteMem[3] < 255) then
              begin
                _nesCPU.stateFlag := _nesCPU.stateFlag or PPU_STATUS_SPRITE;
                Memory[1, PPUSTATUS and $1FFF] := Memory[1, PPUSTATUS and $1FFF] or $40;
              end;
            end;
     // Конец рендеринга.
    if (_nesCPU.cycles <= _nesCPU.cyclesEndVBlank) and ((_nesCPU.stateFlag and PPU_STATUS_NMI) > 0) then
    begin
      _nesCPU.stateFlag := _nesCPU.stateFlag and OFF_STATUS_VBLANK;
      Memory[1, PPUSTATUS and $1FFF] := Memory[1, PPUSTATUS and $1FFF] and $31;     // 71?
      if (Memory[1, PPUCTRL and $1FFF] and $80) = 0 then
      begin
        // Запрещение записи... типо. Но пока ничего не сделано.
        NesCPU.RW_Vram := False;
         Memory[1, PPUSTATUS and $1FFF] := Memory[1, PPUSTATUS and $1FFF] and ($FFFFFFFF - $10);
      end;
    end;
  end;

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

Следующая проблема, с которой пришлось столкнуться, это множественный скроллинг по горизонтали (на разных высотах по Y игра может по-разному смещать экран по X). За смещения отвечает PPUCTRL + PPUSCROLL. И я изначально посчитал, что смещения по X происходят тогда же, когда приходит информация об изменении смещения (PPUCTRL), и оказался неправ. Смещения и информацию о смещениях также надо обрабатывать отдельно. И получил «очень маленькое» согласование в итоге (смотрите процедуру writeMem):

Здесь нас интересуют только адреса $2000 (PPUCTRL) и $2005 (PPUSCROLL)
...
    with NesPPU do
    begin
      addr := addr and $2007;      // Оставляем только одну область
        case addr of
          $2000: begin
                   // Отключаем бит 6. В реальном устройстве NES, если включить данный бит, то можно вывести приставку из строя.
                   Memory[pageMem[addr shr 13], addr and $1FFF] := data and $BF;                             // В "data" все флаги будут корректными.
                 // Указываем приращение видеопамяти.
                   if (data and VERTICAL_WRITE) > 0 then
                     regINC := 32
                   else
                     regINC := 1;
                   if ((data and $80) = 0) {and (testNMIs > 0)} then
                   begin
                     NesCPU.RW_Vram := true;
                    // Memory[PPUSTATUS] := Memory[PPUSTATUS] or $10;
                   end;
                   // Произошла ситуация, когда данные записываются раньше, чем смена информации в PPUCTRL.
                   if NesCPU.cycles >= NesCPU.cyclesStartVBlank then
                   begin
                     inc(NesCPU.dataForPPU._ppuctrl.count);
                     NesCPU.dataForPPU._ppuctrl.data[NesCPU.dataForPPU._ppuctrl.count] := Memory[1, PPUCTRL and $1FFF];
                     i := (29781 - NesCPU.cycles) / 113.667;
                     NesCPU.dataForPPU._ppuctrl.line[NesCPU.dataForPPU._ppuctrl.count] := trunc(i / 8);
                     j := trunc(i);
                     if (j mod 8) = 0 then                   // Остаток деления
                     begin
                       i := (i - Round(i)) * 113.667;
                       if i > 255 then
                         inc(NesCPU.dataForPPU._ppuctrl.line[NesCPU.dataForPPU._ppuctrl.count]);       // Прибавляем если только X вышел за пределы HSync
                     end
                     else
                       inc(NesCPU.dataForPPU._ppuctrl.line[NesCPU.dataForPPU._ppuctrl.count]);         // Прибавляем в любом случае.

                     // Это произошло в 3-D Block, нужно это или нет?
                     if NesCPU.dataForPPU._ppuctrl.line[NesCPU.dataForPPU._ppuctrl.count] = NesCPU.dataForPPU._ppuctrl.line[NesCPU.dataForPPU._ppuctrl.count - 1] then // Это, видимо, что-то другое должно показывать... но что именно? приходит 25 и потом 153 на той же линии.
                     begin
                       NesCPU.dataForPPU._ppuctrl.data[NesCPU.dataForPPU._ppuctrl.count - 1] := NesCPU.dataForPPU._ppuctrl.data[NesCPU.dataForPPU._ppuctrl.count];
                       NesCPU.dataForPPU._ppuctrl.line[NesCPU.dataForPPU._ppuctrl.count] := 0;
                       NesCPU.dataForPPU._ppuctrl.data[NesCPU.dataForPPU._ppuctrl.count] := 0;
                       dec(NesCPU.dataForPPU._ppuctrl.count);
                     end;
                   end
                   else
                     NesCPU.dataForPPU._ppuctrl.data[0] := Memory[1, PPUCTRL and $1FFF];

                   // Указываем, что надо создать текстуры.
                   if ((PPUflags and NEW_CHR_ROM00) > 0) or ((PPUflags and NEW_CHR_ROM01) > 0) then
                     PPUflags := PPUflags and NEW_CHR_CLEAR or NEW_DATA_READY;
                 end;
          $2001: begin
                   Memory[pageMem[addr shr 13], addr and $1FFF] := data;                                   // Также пока всё записываем.
                     NesCPU.dataForPPU._ppumask := data;
                 end;
          $2002: ;                                                            // Только для чтения!
          $2003: reg2003 := data;
          $2004: begin
                   spriteMem[reg2003] := data;
                   reg2003 := byte(reg2003 + 1);                              // Зацикливаем
                 end;
          $2005: begin
                   // Здесь. Надо опрашивать, когда пришло смещение по X и выставлять его отдельно для каждых 8 строк. Итого 30 иксов?
                   // Думаю, надо по одной линии вылавливать и дальше не заморачиваться.
                   if (PPUflags and PPU_FLAG_W) = 0 then
                   begin
                     if NesCPU.cycles >= NesCPU.cyclesStartVBlank then
                     begin
                       inc(NesCPU.dataForPPU.XScroll.count);
                       NesCPU.dataForPPU.XScroll.data[NesCPU.dataForPPU.XScroll.count] := LongWord(data);
                       // При делении на 8 получаем точное число с остатком. Если остаток меньше 255, то перехода нет.
                       i := (29781 - NesCPU.cycles) / 113.667;
                       NesCPU.dataForPPU.XScroll.line[NesCPU.dataForPPU.XScroll.count] := trunc(i / 8);
                       j := trunc(i);
                       if (j mod 8) = 0 then                   // Остаток деления
                       begin
                         i := (i - Round(i)) * 113.667;
                         if i > 255 then
                           inc(NesCPU.dataForPPU.XScroll.line[NesCPU.dataForPPU.XScroll.count]);       // Прибавляем если только X вышел за пределы HSinc
                       end
                       else
                         inc(NesCPU.dataForPPU.XScroll.line[NesCPU.dataForPPU.XScroll.count]);         // Прибавляем в любом случае.
                     end
                     else begin
                       NesCPU.dataForPPU.XScroll.data[0] := data;                    // Отдельный случай. Последнее пришедшее значение запишется в нулевое и мы получим нужные данные для начала прорисовки.
                     end;
                     PPUflags := PPUflags or PPU_FLAG_W;
                   end
                   else begin
                     if data > 240 then
                       YScroll := 0
                     else
                       YScroll := data;
                     PPUflags := PPUflags and CLEAR_FLAG_W;                // Не очищается?
                   end;
                 end;
          $2006: begin
                   if (PPUflags and PPU_FLAG_W) = 0 then
                   begin
                     PPUflags := PPUflags or PPU_FLAG_W;
                   end
                   else begin
                     PPUflags := PPUflags and CLEAR_FLAG_W;                // Не очищается?
                   end;
                   reg2006 := (reg2006 shl 8 + data) and $3FFF;   // Не спрашивайте, почему так — либо сами догадывайтесь, либо изучайте и радиотехнику, и программирование.
                   Exit;
                 end;
          $2007: begin
                   begin
                     if reg2006 < $3000 then
                     begin
                       videoMem[reg2006 and $C00 shr 10]^[reg2006 and $3FF] := data;
                     end
                     else begin
                       videoRAM[reg2006 and $1FFF] := data;
                       if reg2006 = $3F10 then                                // В данном случае нет необходимости дублировать в обратную сторону.
                         videoRAM[$1F00] := data;                              // Мы берём цвет фона только в одном месте.
                     end;
                   end
                   else begin
                     // Когда запись идёт в эти данные, то значит, надо делать новую текстуру.
                     if reg2006 < $1000 then                                  // В большинстве случаев будут переключаться банки памяти полностью.
                       PPUflags := PPUflags or NEW_CHR_ROM00
                     else
                       PPUflags := PPUflags or NEW_CHR_ROM01;
                     videoCIRAM[pageVMem, reg2006 and $1FFF] := data;
                   end;

                   reg2006 := (reg2006 + regINC) and $3FFF;  
                 end;
        end;
      exit;
    end;
...


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

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

Не бойтесь поломать свой код. Но лучше не забудьте сохранить его перед очередной поломкой.

Когда хотел проверить, как PZ_Nes будет работать на Android, то, можно сказать, ужаснулся результату. Я ожидал увидеть хотя бы 40 кадров в секунду, а увидел вот это вот (извиняюсь перед теми, кто читал мою прошлую статью):

Ютуб


Рутуб


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

И, важно! Для меня это будет уроком в дальнейшем — чтобы я не забывал использовать как можно меньше текстур и старался всё уложить в больших текстурах.

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

Ютуб


Рутуб


▍ Отладчик


Здесь я не буду долго задерживаться. Вы можете глянуть исходный код отладчика. Но отладчик мне пришлось делать. Я очень не хотел возиться с логами (хотя они тоже используются) и хотел видеть результат работы инструкций, вызовов, видеть изменения в памяти. Пока это только информация для отображения, для её изменения я пока ничего не реализовывал. Возможно, в дальнейшем буду что-то делать в этом направлении, но пока мне нужно знать, что происходит «под капотом».

Окончание


Как бы я ни хотел всё расписать, всё расписать не получится.

Я выражаю благодарность людям за поддерджку! За критику! За то, что не верили в меня! За то, что верили в меня! Точнее всем, кто хоть как-то участвовал!

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

Я благодарен тем людям, что уже исследовали Nes/Dendy/Famicom и предоставили немало информации по ним! Без этих ресурсов я бы делал всё в несколько раз дольше, ведь я делаю без наличия реального устроства (что плохо, я не могу проверить правильность работы эмулятора и приходится узнавать у людей, правильно получилось реализовать данное решение или нет).


Извиняюсь — релизов пока не предоставляю. Для этого надо хотя бы научить PZ_Nes выбирать загружаемые игры и создать полноценное меню.

Контакты для связи со мной
Ютуб канал
Рутуб канал
Я на gamedev.ru
телега: @SeenkaoSerg

© 2025 ООО «МТ ФИНАНС»

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (0)