Using the UNROM mapper when developing games for Dendy in C

Greetings to all fans of programming all kinds of retro hardware. I can assume that most of the readers of this article had Dendy in childhood (and maybe still have) or another Famicom clone (I have not seen any NES clones in the CIS). Today I propose to discuss the features of developing games for Dendy, NES and Famicom consoles with a mapper UNROM. Those of you who have delved at least a little into the architecture of games for 8-bit consoles have probably heard about mappers. This is an electronic circuit that is located on the cartridge board and expands the capabilities of the console by connecting directly to the processor buses.

There are hundreds of mappers for Dendy, since very often companies developing games made unique mappers to suit their needs. Therefore, today they are available for every taste and color. The simplest mappers allow you to switch memory banks (this was common for all computers of the 1980s), and the most advanced ones (for example, MMC5) already allowed the use of additional hardware interrupts, improved sound, scrolling on two axes, etc.

Architecture Dendy

First, a few words about the console architecture. There is no point in describing it in detail here, since it is presented in many sources. [1]. Here I will give only the main points that are important for describing the interaction with the mapper.

First, the console memory is divided into two separate address spaces: program memory (PRG) and video memory (CHR). Program memory occupies addresses from 0x0000 to 0xFFFF. Here is a table with the general memory structure:

Address range

Size in bytes

Description of the memory area

$0000–$07FF

$0800

2 kilobytes of built-in RAM.

$0800–$0FFF

$0800

Site mirrors $0000–$07FF.

$1000–$17FF

$0800

$1800–$1FFF

$0800

$2000–$2007

$0008

PPU (Picture processing unit) registers.

$4000–$4017

$0018

APU (Audio processing unit) and I/O registers.

$4018–$401F

$0008

Additional APU and I/O registers.

$4020–$FFFF

$BFE0

Address space available for use by the cartridge.

$6000–$7FFF

$2000

Typically used to install an additional 8 kilobytes of RAM (battery powered part for saves).

$8000–$FFFF

$8000

Used to store program memory, often divided into separate banks for convenience. Typically a ROM chip is used.

$FFFA-$FFFF

$0006

Interrupt vectors (store addresses of interrupt handler functions).

From this table we are interested in the memory area $8000–$FFFF. It stores the main program and all additional resources (music, sounds, additional graphics, texts, etc.). Almost always, this data is stored on a ROM chip, that is, it is read-only (but writing there will also be useful to us).

In addition to the main address space, a separate PPU address space is used. We are only interested in the area $0000–$1FFF (8 kilobytes). It stores information about the current set of tiles (tile – a minimum graphics block of 8×8 pixels), which is used to display all graphics in the game (background and sprites).

Range $0000–$1FFF stored on a chip located in a cartridge (this memory is called CHR ROM or CHR RAM). Usage CHR ROM means that a ROM chip is used to store graphics on the cartridge board, and CHR RAM involves the use of a RAM chip.

Now let's talk about the structure of storing tiles in video memory.

Structure of storing tiles in PPU memory

As I said above, all graphics consist of 8×8 pixel tiles. Moreover, each pixel is described by 2 bits, that is, within one tile we only have access to 3 colors (zero color means transparency). Since we use 2 bits for each pixel, storing one tile requires 16 bytes.

The way of storing information about the colors of each pixel is quite original. The image is divided into two layers of 8 bytes. The first layer stores information about the least significant bits of colors, that is, exactly one byte is spent on each line of the tile (where the least significant bit is the rightmost pixel). In the second layer everything is the same, only for the most significant bits. Below is a good illustration of the encoding of a tile that represents the symbol “1/2”:

https://www.nesdev.org/w/images/default/a/a4/One_half_fraction_CHR.png

The left column of numbers is the first 8 bytes of the tile, which describe the first layer (the low-order color bits), and the right column is the next 8 bytes (the high-order color bits).

The bytes describing the layers are stored sequentially in memory, which is very convenient for editing CHR RAM, but we'll talk about this a little later.

Features of the UxROM mapper

So we come to the topic of the article, namely, the features of the UxROM mapper.

UxROM is a general name for several mappers that use similar circuitry and mechanisms for switching memory banks (UNROM, UOROM and several more of their variations). UNROM has 64 or 128 KB PRG-ROM (all memory is divided into banks of 16 KB) and 8 KB CHR-RAM.

If a 64 KB ROM chip is used, we have only four banks of 16 KB each. The first three of them are switchable (the currently selected bank is located in the address range $8000-$BFFF), and the latter is fixed ($С000-$BFFF). A fixed bank is used to store the main program code (implementation of interrupt handlers, functions for loading data into CHR-RAM, functions for switching memory banks, etc.). Switchable banks are used to store all other data.

Switching memory banks with the UNROM mapper

To switch memory banks, you need to write a byte with the number of the required bank to any address in the range $8000-$FFFF. The switching occurs due to the fact that the lower 4 bits of the data bus are connected to the inputs of the mapper, which is implemented on only two microcircuits: 74161 (4-bit counter with parallel input and latch) and 7432 (four “OR” logic elements).

74161 is used to remember the current bank number, and the “OR” elements form the most significant bits of the address for PRG ROM. The described mapper diagram is shown below.

http://elektropage.ru/cartmod/nes/nes_cart/FC_UxROM_Schematics.png

But it is worth considering that at the address into which the memory bank number is written, a byte must be stored that is equal in value to the number of the bank that is written to it. The easiest way to solve this problem is to create an array with the numbers of memory banks in a fixed memory bank.

For clarity, let's write a function for switching memory banks in C (all code examples will be relevant for the cc65 compiler [4]). We create an array with the numbers of memory banks:

// Указываем сегмент памяти
// RW_PRG должен располагаться в фиксированном банке и
// иметь режим «Чтение и запись»
#pragma data-name (push, "RW_PRG")
    // Массив в PRG ROM для переключения банков маппера
    // Для переключения банка нужно записать в элемент массива число, 
    // которое хранится в этой ячейке памяти
    unsigned char bank_table [] = {
        0x00, 0x01, 0x02
    };
// Возвращаемся в исходный сегмент кода
#pragma data-name (pop)

Функция переключения банков:
// Через current_bank задаём номер требуемого банка памяти
void switch_bank_prg_rom (void) {
    bank_table [current_bank] = current_bank;
}

Organization of memory structure

The mechanism for switching memory banks is quite simple and should not raise any questions. But the correct organization of the PRG ROM memory structure is not so obvious. Let's look at an example configuration file from a real game on the UNROM mapper. The configuration file presented below is required when assembling the project; if it is not configured correctly, the game will either not work or will not compile at all.

# Разметка памяти
MEMORY {
#RAM Addresses:
    # Zero page (Нулевая страница), часть адресов используется консолью, все 255 байт использовать не получится
    ZP: start = $00, size = $100, type = rw, define = yes;
	
    # Здесь хранится копия таблицы ОАМ (таблица информации о всех спрайтах - 64 штуки)
    # 4 байта на один спрайт
	OAM1: start = $0200, size = $0100, define = yes;
	# ОЗУ для общего пользования - 1024 байта
	RAM: start = $0300, size = $0400, define = yes;

#INES Header:
    # Эта часть памяти используется для заголовка INES, который нужен для работы эмулятора
    HEADER: start = $0, size = $10, file = %O ,fill = yes;

#ROM Addresses:
    # Количество банков должно совпадать с количеством банков указанном в iNES заголовок 
    # Переключаемые банки по 16 килобайт ($4000)
    PRG0: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
    PRG1: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
    PRG2: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
    # Используется половина PRG ROM
    PRGF: start = $c000, size = $3ffa, file = %O ,fill = yes, define = yes;

    # Hardware Vectors at end of the ROM (тут хранятся адреса обработчиков прерываний, всего 3 прерывания)
    VECTORS: start = $fffa, size = $6, file = %O, fill = yes;
}
# Тут объявляются сегменты кода и прикрепляются к реальным участкам памяти которые описаны в блоке MEMORY
# Так же указываются режимы работы этих сегментов, смотрите документацию компилятора сс65
SEGMENTS {
    HEADER:   load = HEADER,         type = ro;

    STARTUP:  load = PRGF,            type = ro,  define = yes;
    LOWCODE:  load = PRGF,            type = ro,                optional = yes;
    INIT:     load = PRGF,            type = ro,  define = yes, optional = yes;

    BANK0:     load = PRG0,            type = ro,  define = yes;
    BANK1:     load = PRG1,            type = ro,  define = yes;
    BANK2:     load = PRG2,            type = ro,  define = yes;

    CODE:      load = PRGF,            type = ro,  define = yes;

    RODATA:    load = PRGF,            type = ro,  define = yes;
    RW_PRG:    load = PRGF,            type = rw,  define = yes;
#run = RAM, это означает, что данные или код, которые загружаются в PRG ROM (load = PRGF),
#будут копироваться и выполняться из RAM при запуске программы.
    DATA:     load = PRGF, run = RAM, type = rw,  define = yes;

    VECTORS:  load = VECTORS,        type = rw;
    
    BSS:      load = RAM,            type = bss, define = yes;
    HEAP:     load = RAM,            type = bss, optional = yes;
    ZEROPAGE: load = ZP,             type = zp;
	OAM:	  load = OAM1,			 type = bss, define = yes;

	ONCE:     load = PRGF,            type = ro,  define = yes;
}

Block MEMORY describes the physical layout of various memory locations, and a block SEGMENTS describes the code segments that are specified during programming. We are interested in the following lines from the block MEMORY:

# Переключаемые банки по 16 килобайт ($4000)
    PRG0: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
    PRG1: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
    PRG2: start = $8000, size = $4000, file = %O ,fill = yes, define = yes;
    # Используется половина PRG ROM
    PRGF: start = $c000, size = $3ffa, file = %O ,fill = yes, define = yes;

PRG0, PRG1, PRG2 – These are switchable memory blocks. The block that you specified first in the list will be located at the beginning of the ROM file that stores all the game data and is necessary to run it in the emulator, so be careful when creating the configuration file. The switched banks must start from the same address ($8000 in our case) and have the same size ($4000 or 16 KB in our example).

From the block SEGMENTS We are interested in these lines:

BANK0:     load = PRG0,            type = ro,  define = yes;
BANK1:     load = PRG1,            type = ro,  define = yes;
BANK2:     load = PRG2,            type = ro,  define = yes;
CODE:      load = PRGF,            type = ro,  define = yes;
RW_PRG:    load = PRGF,            type = rw,  define = yes;

BANK0, BANK1, BANK2 — names of segments of switchable banks, which we will specify during development.

CODE is a segment that instructs the collector to place data into a fixed memory bank.

RW_PRG needed for the correct placement of an array with indexes of memory banks (above I gave an example of using this segment when creating an array).

There are many more features and parameters in the configuration file, but a detailed description requires a separate article. Here there is detailed information on the structure of the configuration file [5].

Loading data into CHR RAM

Ability to load and edit data in CHR RAM is the most interesting and useful feature of the UNROM mapper. When using CHR RAM in a cartridge, before starting the game itself, it is necessary to initialize the video memory with the necessary set of tiles, since after turning on the console the video memory is filled with garbage. To do this, let's write a function to load data into CHR RAM:

// Адреса регистров
#define PPU_CTRL        *((volatile unsigned char*)0x2000)
#define PPU_MASK        *((volatile unsigned char*)0x2001)
#define PPU_STATUS      *((volatile unsigned char*)0x2002)
#define OAM_ADDRESS     *((volatile unsigned char*)0x2003)
#define SCROLL          *((volatile unsigned char*)0x2005)
#define PPU_ADDRESS     *((volatile unsigned char*)0x2006)
#define PPU_DATA        *((volatile unsigned char*)0x2007)
#define OAM_DMA         *((volatile unsigned char*)0x4014)
#define JOYPAD1         (*(volatile unsigned char*)0x4016)
#define JOYPAD2         (*(volatile unsigned char*)0x4017)
#define PRG_DATA_ADDR   ((volatile unsigned char*)0x8000)

// Функция для копирования данных из PRG ROM в CHR RAM
void copy_prg_rom_to_chr_ram() {
    // Начинаем заполнять CHR RAM  с нулевого адреса
    PPU_ADDRESS = 0x0;
    PPU_ADDRESS = 0x0;
    // Копирование данных (8 килобайт копируем из PRG ROM)
    // i_long — unsigned int 
    for (i_long = 0; i_long < 0x2000; ++i_long) {
        PPU_DATA = PRG_DATA_ADDR [i_long];
    }
}

Writing to CHR RAM occurs through the use of registers PPU_DATA (when written to it, the byte is written to the specified PPU address) and PPU_ADDRESS (first the high byte of the address is written to it, and then the low byte). That is, to write one byte, we indicate the video memory address through the register PPU_ADDRESSand then in PPU_DATA load the required byte of data. After writing a byte to PPU_DATA active address (PPU_ADDRESS) is automatically incremented, so you only need to specify it once. The example above downloads 8 KB of data starting from the address 0x8000. In your case, the address can be anything (it must point to the beginning of the .chr file). Exactly 8 KB are loaded, since that’s how much the entire set of tiles takes up.

Once initialized, CHR RAM can be used in the same way as CHR ROM.

Editing individual CHR RAM tiles

Editing individual tiles allows you to implement animation of both backgrounds and sprites. Editing CHR RAM is especially useful when animating backgrounds, since by replacing the background tile you will change all parts of the background where this tile is used (this way you can implement the animation of a large number of candles or torches, rotation of gears, etc.).

In addition to convenient background animation, editing video memory allows you to save space in the address space 0x0000 – 0x1FFF, since only one animation frame can be stored at a time (for example, in the Battletoads game, only one animation frame is stored in CHR RAM at a time).

To better understand how this works, let's write a function that will replace one specific tile in CHR RAM.

// Перезаписывает тайл в CHR RAM из массив по указателю
// Указатель должен указывать на массив из 16 байт
// Тайл состоит из 16 байт (8 байт первый слой, 8 байт второй слой)
// Цвет хранится в виде 2 бит (от b00 до b11)
// Первые 8 байт - это младшие разряды кода цвета(слой 0), 1 байт - 1 стока тайла
// Вторые 8 байт - это старшие разряды тайла (слой 1)
/* Пример:
    // Адрес тайла в CHR 0x_NN0
    NN адреса - это номер тайла из таблицы имен
    PPU_ADDRESS = 0x12; 
    PPU_ADDRESS = 0xC0;
    // Массив с 16 байтами, которые хранят тайл
    p_text = chr1; 
    set_tile_to_chr ();
*/
void set_tile_to_chr (void) {
    // Записываем первый байт тайла (младший слой)
    PPU_DATA = *(p_text + 0);
    PPU_DATA = *(p_text + 1);
    PPU_DATA = *(p_text + 2);
    PPU_DATA = *(p_text + 3);
    PPU_DATA = *(p_text + 4);
    PPU_DATA = *(p_text + 5);
    PPU_DATA = *(p_text + 6);
    PPU_DATA = *(p_text + 7);
    // Записываем старший байт байт тайла
    PPU_DATA = *(p_text + 8);
    PPU_DATA = *(p_text + 9);
    PPU_DATA = *(p_text + 10);
    PPU_DATA = *(p_text + 11);
    PPU_DATA = *(p_text + 12);
    PPU_DATA = *(p_text + 13);
    PPU_DATA = *(p_text + 14);
    PPU_DATA = *(p_text + 15);
}

Pointer p_text stores the address of the initial element of the array, which stores 16 bytes describing one tile (the tile storage structure is described at the beginning of the article). The function does not use a loop, since its implementation requires checking conditions, incrementing the counter and copying its value. Optimization is very important when developing programs for old hardware (we can edit video memory only at the moment between frames, which only takes about 2200 processor cycles).

To select the desired tile via PPU_ADDRESS set the tile number (they are numbered from 0x00 to 0xFF) using a mask 0xPNN0Where P – video memory page number – 0 or 1, NN — tile numbers. The address consists of 4 bytes. The high byte specifies the video memory page (there are only two of them, one is used to display backgrounds, and the other for sprites), and the next two bytes (middle) indicate the tile number, everything is very simple.

The NES architecture is very well thought out, but it also has its share of strange moments. For example, by writing 16 bytes to address 0x1FF0, you will edit tile number 0xFF (255th tile) from the second page of video memory.

Let's look at a real example of how editing works CHR RAM. We create an array that stores the tile data (bytes can be easily obtained by opening the .chr as a binary file):

// Символ восклицательного знака
const unsigned char new_tile [] = {
    0x38, 0x7c, 0x7c, 0x7c, 0x38, 0x00, 0x38, 0x00,
   0x30, 0x78, 0x78, 0x78, 0x30, 0x00, 0x30, 0x00};

Now we write code that will wait for the user to press the “A” button, and after pressing, we will load our new tile into video memory:

while (1)
    {   
        Wait_Vblank();
        Get_Input ();
        if (A_PRESSED) {
            Wait_Vblank();
            PPU_ADDRESS = 0x00;
            PPU_ADDRESS = 0x00;
  // Массив с 16 байтами, которые хранят тайл
            p_text = new_tile; 
            set_tile_to_chr();
            PPU_ADDRESS = 0x00;
            PPU_ADDRESS = 0x00;
            break;
        }
    }

Wait_Vblank(); implements waiting for the end of the frame. This is necessary, since we can work with video memory only between frames (the return time of the CRT beam).

Now let's run our code in the emulator Fceux [6] and open the window PPU Viewer, which shows the contents of video memory in the form of tiles. What it looks like before and after editing:

After editing the CHR ROM, an exclamation mark symbol appeared in place of the tile number 0x00. Everything is working.

Conclusion

It is quite difficult to cover all the features of game development for Dendy in one publication, so I tried to describe the interaction with the UNROM mapper in as much detail as possible. I chose it for this article because it is quite easy to use and provides ample opportunities for the programmer. In addition, a cartridge with UNROM is quite easy to make yourself, since it is based on cheap microcircuits (64/128 KB ROM, 8 KB RAM and two discrete logic chips), and the board can be printed with LUT or ordered ready-made from the Chinese. Problems can only be with the ROM firmware.

Programmers for flashing microcircuits with parallel data input are quite expensive (and if you have UV-erasable ROM, you will also need a UV lamp). For example, with the MMC3 mapper (the most common) there would be many more problems, since it requires a special chip of the same name (ASIC), which would have to be removed from the donor or implemented on an FPGA, which is much more expensive and complicated.

On this lyrical note I will end my story. I hope that the article was useful to you or at least made the structure of your favorite games from childhood a little clearer.

Make and play good games. Thank you all for your attention.

useful links

[1] https://www.nesdev.org/wiki/NES_reference_guide

[2] https://www.nesdev.org/wiki/PPU_pattern_tables

[3] http://elektropage.ru/publ/handmade/handmade/delaem_kartridzh_dendy_nes_chast_4_mappery_serii_uxrom/1-1-0-161

[4] https://www.cc65.org/

[5] https://www.cc65.org/doc/ld65-5.html

[6] https://fceux.com/

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *