NV3 Getting Started

From NvWiki
Revision as of 02:04, 11 November 2026 by Starfrost (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

You can program your completely obsolete GPU the way that the company intended, using boring APIs such as Direct3D and OpenGL. Or you can do it in the cool way. This page details how to be cool within extremely niche circles. The choice is yours.

Introduction

Most of the pages on this website are for people interested in how Nvidia's hardware works. You don't need to know everything about how the RIVA 128 works in order to actually program the hardware; you just need to be able to understand the interfaces to it and some of the registers. This is not for the faint of heart. You are directly programming complex graphics hardware (old does not mean simple - the RIVA 128 has 3.5 million transistors, far more than any human can individually comprehend) that is not yet fully understood, but advances are being made all the time, so check back to this page for new updates. Eventually versions of this page will be made for the NV1 and RIVA TNT, and hopefully the Riva TNT2 and maybe, one day, even the GeForce 256 and its Celsius/NV1x brethren, such as the GeForce 2 and the GeForce 4 MX. If you want to write a game or some other piece of software in this way, you will be effectively writing a mini-GPU driver to manage the GPU hardware resources combined with whatever software you may wish to develop. If you want to write raw-NV software for Windows, Linux or any other operating system you will need to write a kernel driver that exposes the GPU's raw interface to usermode before you can write anything at all (this driver may be done in the future). You can get around this by using something like DJGPP in order to write a DOS application using a 32-bit memory model, without any of DOS's 16-bit segmented cruft. This is the recommended approach as it has the minimum amount of overhead and boilerplate code, plus working under a DOS box such as the Windows 9x DOS box.

Concepts

Before we start programming, there are many concepts, some that are more general to raw-level GPU development and other very specific to Nvidia, that need to be explained.

Pre-initialisation

If you are programming a game against a graphics card, the graphics card initialisation has already been done for you by the vendor-provided drivers. But if you are doing raw-level programming of a GPU, you will have to do this step manualy; this is done by writing directly to memory-mapped I/O addresses decoded via PCI BARs (Base Address Registers); in NV3, there are two, that are both 16 MB. But these are not mapped to your CPU's MMU by default, and for some cards (like NV1) the card will be completely dead when you start to interact with it - it will not even respond to MMIO accesses. However, in most cases for the RIVA 128, this is not actually required (if your card was integrated of a motherboard, this may not be done), as the RIVA 128's PCI configuration (such as its bus mastering capability and addresses for both BAR0 and BAR1) are pre-configured by the Video BIOS. This means that all you have to do is map the 32 MB of MMIO the NV3 uses, which can be done using code like this in DJGPP: (example from nvplay).

    // only top 8 bits actually matter
    uint32_t bar0_base = PCI_ReadConfig32(current_device.bus_info.bus_number, current_device.bus_info.function_number, PCI_CFG_OFFSET_BAR0);
    uint32_t bar1_base = PCI_ReadConfig32(current_device.bus_info.bus_number, current_device.bus_info.function_number, PCI_CFG_OFFSET_BAR1);

    /* According to the datasheet only the most significant 8 bits matter */
    bar0_base &= 0xFF000000;
    bar1_base &= 0xFF000000;

    Logging_Write(LOG_LEVEL_DEBUG, "NV3 - PCI BAR0 0x%08lX\n", bar0_base);
    Logging_Write(LOG_LEVEL_DEBUG, "NV3 - PCI BAR1 0x%08lX\n", bar1_base);
    
    /* We need to allocate an LDT for this */
    /* So start by allocating a physical mapping. */

    __dpmi_meminfo meminfo_bar0 = {0};
    __dpmi_meminfo meminfo_bar1 = {0};

    meminfo_bar0.address = bar0_base;
    meminfo_bar0.size = NV_MMIO_SIZE;
    meminfo_bar1.address = bar1_base;
    meminfo_bar1.size = NV_MMIO_SIZE; //this will change

    current_device.ramin_start = bar1_base + NV3_RAMIN_START;

    __dpmi_physical_address_mapping(&meminfo_bar0);
    __dpmi_physical_address_mapping(&meminfo_bar1);
    
    Logging_Write(LOG_LEVEL_DEBUG, "NV3 Init: Mapping BAR0 MMIO...\n");

    /* Set up two LDTs, we don't need one for ramin, because, it's just a part of bar1 we map differently */
    current_device.bus_info.bar0_selector = __dpmi_allocate_ldt_descriptors(1);
    __dpmi_set_segment_base_address(current_device.bus_info.bar0_selector, meminfo_bar0.address);
    __dpmi_set_segment_limit(current_device.bus_info.bar0_selector, NV_MMIO_SIZE - 1);

    Logging_Write(LOG_LEVEL_DEBUG, "NV3 Init: Mapping BAR1 (DFB / RAMIN)...\n");

    current_device.bus_info.bar1_selector = __dpmi_allocate_ldt_descriptors(1);
    __dpmi_set_segment_base_address(current_device.bus_info.bar1_selector, meminfo_bar1.address);
    __dpmi_set_segment_limit(current_device.bus_info.bar1_selector, NV_MMIO_SIZE - 1); // ultimately the same size

The first step of this code is using the PCI BIOS; the PCI BIOS is used from real mode, or a DPMI-compliant 32-bit environment, by invoking interrupt 1A with AH set to 0xB1 and AL set to the function number. The function number for ReadConfig32 is hence B10Ah, or Read Config Dword) in order to read the BAR 0 and BAR 1 addresses from the chip's PCI configuration. In order to use all of the required PCI BIOS services, the version of the PCI specification imlemented by your motherboard must be version 2.1 or beter; you should test for this by using INT 1A, function B101h, and checking if the value in the BX register is at least 0x0201, or PCI version 2.1. Nearly all PCI compliant devices should be compliant with version 2.1 of the PCI specification, but some of the very earliest PCI chipsets were not, so still use this function to check for compliance.

Then the two addresses returned must have their lower 24 bits masked off, so that they are aligned to a 16 MB boundary, as the lower byte is used for determining if prefetching is enabled (which it is) rather than store the lower byte of the address. Additionally, the RIVA 128 only decodes the upper 8 bits of its PCI BAR values, so if these values are not aligned, MMIO addresses will be misaligned, which is fatal for graphics programming. This is especially important if you want to change the PCI BARs later. Since these addresses are not mapped within your CPU's memory management unit (MMU), they now need to be mapped in order to actually perform memory-mapped I/O. This is done, on x86 architectures, by adding additional segment descriptors to the local descriptor table (LDT) of your CPU, with a base address for each BAR and a "limit" (MMU parlance for size) of 16 MB. In DJGPP, this is done by #including the file dpmi.h allocating two LDT descriptors with the function __dpmi_allocate_ldt_descriptors(1);. The parameter 1 is the number of descriptors to allocate, as it is is easier syntactically to allocate them one at a time. The function returns an integer that is later used to actually interface with the MMIO space. After this, the functions __dpmi_set_segment_base_address and __dpmi_set_segment_limit are done to actually assign the base and limit values to the LDT descriptors.

It is highly recommended to write utility functions to make it easier to read and write to MMIO space. Most Nvidia registers are 32-bit, but 8-bit versions of the functions are also required in order to write to 8-bit legacy VGA registers. In DJGPP, these functions would be written like this, with bar0_selector being the selector that you allocated earlier; these functions are very easy to port to any other 32-bit flat DOS environment of your choosing:

/* Read 8-bit value from the MMIO */
uint8_t NV_ReadMMIO8(uint32_t offset)
{
    return _farpeekb(current_device.bus_info.bar0_selector, offset);
}

/* Read 32-bit value from the MMIO */
uint32_t NV_ReadMMIO32(uint32_t offset)
{
    return _farpeekl(current_device.bus_info.bar0_selector, offset);
}

/* Write 8-bit value to the MMIO */
void NV_WriteMMIO8(uint32_t offset, uint8_t val)
{
    _farpokeb(current_device.bus_info.bar0_selector, offset, val);
}

/* Write 32-bit value to the MMIO */
void NV_WriteMMIO32(uint32_t offset, uint32_t val)
{
    _farpokel(current_device.bus_info.bar0_selector, offset, val);
}

Initialisation

After this the actual initialisation of the GPU can commence. But before that, what do you actually get? from those two descriptors?

BAR0 is a pointer into the GPU's register space. Registers are very sparsely grouped together; the vast majority of addresses decode to open bus (often, but not entirely consistent, garbage, seemingly related to what the GPU is currently writing into Video RAM). A header file with the addresses of the registers can be obtained here: ((TODO : UPLOAD UNOFFICIAL NV3_REF.H)); see NV3 memory mapping for an explanation of which regions correspond to which functional blocks within the chip. It also contains (at offset 0x110000) a mirror of the Video BIOS, if one is installed (an integrated chip may not have one).

Warning: If the "mediaport" region (at 0x200000-0x200FFF) is written to or read from without something being plugged into the mediaport, or certain other regions like the object registers, the entire PCI bus will lock up, and the system will hard freeze, with the only option for recovering the system being using the rocker switch (the switch at the back).

Note: For some reason, at least under Windows 9x, if you open a DOS box, the top left corner of the window will be mirrored in the legacy VGA space (BAR0 0xA0000-0xBFFFF), in whatever graphics format the GPU is set up to use, but interlaced. This is, quite frankly, bizarre.

BAR1 has two primary purposes on NV3. The first chunk of the BAR1 space is a mirror of Video RAM (usually 4 or 8 MB on a RIVA 128 ZX, rarely 2 MB), for basic ("dumb") framebuffer operations. The intended way of using Video RAM is to use the NV3 PGRAPH B* registers, via submitting "Image in Memory" objects, to configure any of up to four buffers with a pixel format, start position and size, and then assigning graphics objects to any of them (you can write to all of them at once) in instance memory, which is the other primary use of BAR1. Instance memory is located at 0xC00000 and basically holds the current loaded objects (within the current DMA channels) and allows you to execute "methods" (graphics commands) on them. This is a very simplified explanation, which will be further elaborated on later.

If you have a working RIVA 128 and dump VRAM from it (using nvplay or some other feature), it has a very different layout depending on what you are actually doing with the card:

  • If you are in DOS, there won't be much here. At most, you'll see a weird 64-bit format that has something to do with how text-mode is displayed, where the 8th byte is the actual display, likely having something to do with the internal representation of the VGA font data; the actual VGA RAM is mirrored in BAR0.
  • If you are just doing 2D rendering, the format of the data takes the current graphics mode; if you are in 8-bit indexed colour mode, the format will be indexed colour (how to write colour indexes will be explained later), in 32-bit colour mode the format will be ARGB8888 (at least by default), and so on. The only minor exception is for 16-bit rendering, which for some reason, is actually 15-bit RGB555 unless PRAMDAC_GENERAL_CONTROL bit 12 is set, in which case it will be 16-bit RGB565 format. Note that the GPU often uses a different format during its own internal operations and downconverts to the format for the current surface later.

The Windows GDI driver's VRAM management model is pretty simple; it just puts the visible display at VRAM address 0 (by setting buffer 0 to offset 0) and does not use any additional buffers (the GPU supports up to 4), and stores icons and other currently unused surfaces offscreen, right after the screen for optimisation reasons. It copies needed GDI surfaces into visible VRAM when needed.

  • If you are doing 3D rendering: In windowed mode, GDI still takes up memory in order to display the frame buffer. This can be a serious memory usage bottleneck, especially on a 4 MB card; even an 800x600x16bpp framebuffer takes up nearly 1/4th of VRAM (960 KB), ignoring any hidden objects! While the NV3 can swap textures out to system RAM, this is much slower and hence one of the reasons, among several others, why windowed-mode games tend to run slower. This is not helped by the fact that you have to render 3D graphics in 16-bit mode, as, although the NV3 architecture is perfectly happy outputting 32-bit fragments (output pixels), it can only accept 16-bit texture formats! Not being able to texture anything is a serious limitation in 3D rendering, so the drivers simply prevent you from doing so on Windows. After GDI and its surfaces, you get textures used by the current 3D scene (which can also be stored in system memory), which are usually swizzled in some way by the driver (do not confuse this feature with actual "texture swizzling", this seems to just be a memory usage optimisation by the Direct3D and OpenGL drivers).

After that you get two buffers; one is the current, presented 3D scene in its entirety and the next presents as a strange rainbow-looking thing, which is the Z-buffer (which stores the order in which to render polygons, for proper depth perception). Now you've got a tour of how VRAM works, let's start programming this thing!

By default, most of the subsystems of the NV3 are actually turned off. This is done, presumably, in order to reduce power use by not having unneeded systems running while they are not being used. In order to turn on the GPU, you need to write 0x11111111 to NV_PMC_ENABLE (0x200) in order to turn on all subsystems. You also need to write the value 0x3 to NV_PMC_INTR_EN_0 (0x140) in order to allow critical interrupts, like the VBlank interrupt, to fire. This is how nvplay does it:


    /* Power up all GPU subsystems */
    Logging_Write(LOG_LEVEL_DEBUG, "NV3 Init: Enabling all GPU subsystems (0x11111111 -> NV3_PMC_ENABLE)...");
    NV_WriteMMIO32(NV3_PMC_ENABLE, 0x11111111);
    Logging_Write(LOG_LEVEL_DEBUG, "Done!\n");

    /* Enable interrupts (test) */
    Logging_Write(LOG_LEVEL_DEBUG, "NV3 Init: Enabling interrupts...");
    NV_WriteMMIO32(NV3_PMC_INTERRUPT_ENABLE, (NV3_PMC_INTERRUPT_ENABLE_HARDWARE | NV3_PMC_INTERRUPT_ENABLE_SOFTWARE));

(NV3_PMC_INTERRUPT_ENABLE_HARDWARE | NV3_PMC_INTERRUPT_ENABLE_SOFTWARE) is a bitfield representation of 0x3 - bit 1 (0x2) enables software interrupts, while bit 0 (0x1) enables hardware interrupts.