
Всем привет! С вами на связи снова Сергей, и я продолжаю творить «чудо».
В прошлой статье я немного задел тему эмуляции процессора. Советую почитать, кто не читал (ну, опять же, на ваше усмотрение — если решили сделать эмулятор сами, то лучше прочитать). Кстати, я обновил ту статью и немного пробежался по прерываниям. В этой статье, видимо, будет ещё больше технической информации — по правильной реализации памяти и работе с ней. И, наконец, доберёмся до видеоадаптера (PPU).
▍ Отступление
Я пока копаюсь на просторах интернета, нахожу постоянно какую-то информацию. И кроме информации на сайте nesdev (где достаточно немало информации), нашёл вот такой сайт с документами (пробегаясь позже по сайту, я понял, что уже заходил на него, но вот вкладку «документы» не открывал).
Несколько книжек по программированию на веб-архиве:
- Programming the 6502, Rodney Zaks.
- 6502 application book, Rodney Zaks.
- 6502 games, Rodney Zaks.
- 6502 assembly language programming, Lance A. Leventhcil
И ресурс 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) |
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 и произвести сам вывод данных на экран. Сколько же всего тут надо изучить… циклы, рендеринг, тайминги, синхронизацию кадров и, конечно же, реализацию на разных эмуляторах.
▍ Как именно будет реализовано?
Это было неверным решением, как я понял позже. Я неправильно воспринял информацию и прицепил инструкции 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:
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 содержатся числовые значения для выбора цвета из палитры для фона и для спрайтов отдельно.
Давайте ещё взглянем сюда:

Заметьте: я память атрибутов также указал в основной памяти. Это ненеправильно, есть моменты, когда атрибуты могут быть номером для спрайта/тайла, но это очень редко.
На рисунке область памяти, которая должна содержать номера спрайтов/тайлов. В данном случае, это просто адреса каждой ячейки памяти. Красным я выделил несколько ячеек памяти и внизу тоже выделил память. Внизу память с атрибутами для тех ячеек, что наверху (указано стрелкой). Точнее, две нижние строки атрибутов действуют на всю данную память 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, то пишем данные во вторую текстуру. Если в обоих битах единицы, то пишем данные в третью текстуру (да, у нас одна текстура, но показываю условный пример, чтобы было более понятно, как всё происходит).
После готовности всех данных мы должны передать их в текстуру. Чтобы вас не томить, я предоставлю код. Сразу обратите внимание — я данные «переворачиваю», потому что в памяти видеокарты она хранится в перевёрнутом состоянии.
// Сначала пробуем сделать текстуры просто исходя из данных в памяти.
// Точнее на одной линии два значения одного спрайта (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.
А пока реализуем процедуру вывода спрайта/тайла.
Последние исправления касались «обрезания» спрайтов/тайлов слева (когда левые 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;
И сразу приложу ещё одну картинку:

Теперь рассмотрим код, Рисунок 1 и Рисунок 2. Как я и говорил ранее, для спрайтов нет проблемы выделить цвет. Он указан в третьем байте, в битах 0 и 1.
Сложность возникает при выборке цвета для тайлов (кстати, в Nes тайлов почти всегда больше на экране, чем спрайтов). В процедуре CalcColor мы производим эти вычисления на основании адресов из знакогенераторов и их атрибутов. На Рисунке 1 вы можете посмотреть адреса первого знакогенератора и его атрибутов. Выборка в коде реализована согласно этих адресов при изменении экранной страницы, в коде надо просто переключиться на нужный банк памяти.
Изначально я делал выборку посредством If, но давайте будем честны, подобная выборка дорога в реализации в большинстве случаев. Ведь надо сделать выборку из 960 тайлов (позже был реализован вывод более 1000 тайлов, для скроллируемого окна).
Выборку для байта я сделал, но реализовать выборку для битов из байта было сложнее. Адресов много, и как сделать правильную выборку значений? Ответ дал мне второй рисунок. Я побитово исследовал некоторые адреса, где увидел закономерность (на рисунке она уже указана, да и на рисунке красными стрелками показаны биты 6, а коричневыми биты 2). И что же я увидел на этом рисунке? Какие-то два бита: 2 и 6. И привязал их к чему-то…
На рисунке 2 присутствует ошибка по тому, за какую часть отвечает бит 2.


Один байт цвета привязан к 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 ещё нет, для его правильной работы нужны ещё согласования с процессором).
Вообще, некоторые решения в 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):
...
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.
- Здесь ZenGL для сборки проекта.
Извиняюсь — релизов пока не предоставляю. Для этого надо хотя бы научить PZ_Nes выбирать загружаемые игры и создать полноценное меню.
© 2025 ООО «МТ ФИНАНС»
Telegram-канал со скидками, розыгрышами призов и новостями IT ?
