启动NES模拟器,打开我们经典的超级马里奥1。

选择工具->查看器->图形查看器。会出现如下的一个窗口。

141431758.png

在该窗口上单击,画面还会改变。

这些画面有什么意义,VirtiaNES模拟器是如何显示出这些画面的?

以上几个问题就是这篇博文的主题了。


响应函数

菜单选项 “图形查看器” 的响应函数是:

WNDCMD CMainFrame::OnViewCommand( WNDCMDPARAM )

所在文件 Source Files\MainFrame.cpp

VS系列IDE的查找功能应该算是比较强大的,函数具体在哪一行我就不讲了。以下是该响应函数的代码:

WNDCMD  CMainFrame::OnViewCommand( WNDCMDPARAM )
{
    if( !Emu.IsRunning() || !Nes )
        return;
    switch( uID ) {
        case    ID_VIEW_PATTERN:
            if( !m_PatternView.m_hWnd ) {
                m_PatternView.Create( HWND_DESKTOP );
            }
            ::SetWindowPos( m_PatternView.m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE|SWP_NOSIZE );
            break;
        case    ID_VIEW_NAMETABLE:
            if( !m_NameTableView.m_hWnd ) {
                m_NameTableView.Create( HWND_DESKTOP );
            }
            ::SetWindowPos( m_NameTableView.m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE|SWP_NOSIZE );
            break;
        case    ID_VIEW_PALETTE:
            if( !m_PaletteView.m_hWnd ) {
                m_PaletteView.Create( HWND_DESKTOP );
            }
            ::SetWindowPos( m_PaletteView.m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE|SWP_NOSIZE );
            break;
        case    ID_VIEW_MEMORY:
            if( !m_MemoryView.m_hWnd ) {
                m_MemoryView.Create( HWND_DESKTOP );
            }
            ::SetWindowPos( m_MemoryView.m_hWnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOMOVE|SWP_NOSIZE );
            break;
        default:
            break;
    }
}

第1行,函数的参数WNDCMDPARAM是个宏定义,其实是HWND hWnd, UINT uID

hWnd是父窗口也就是主界面的句柄。uID是菜单项的ID。

13-18,其余行的代码暂且不管。由于我们选择的是图像查看器,所以就来到了这个分支。如果是第一 次执行这段代码,m_PatternView中的窗口是还未创建的。

m_PatternView中的窗口创建成功之后,将窗口显示出来,这个函数的任务就结束了。


CPatternView

所在文件 Source Files/PatternView.cpp Header Files/PatternView.h

上节中的 m_PatternView 就是类CPatternView的一个对象。


在CPatternView中,Create函数创建窗口,初始化数据。由于创建了窗口,所以响应函数OnCreate被触发。OnCreate里会开启一个定时器,进而又触发响应函数OnTimer。

好了,OnTimer才是这个类的重点。OnTimer会不断读取最新数据,并显示在窗口中。


位图信息头的初始化如下(Create函数中)

m_BitmapHdr.bih.biSize        = sizeof(BITMAPINFOHEADER);
m_BitmapHdr.bih.biWidth       = 128;
m_BitmapHdr.bih.biHeight      = -256;
m_BitmapHdr.bih.biPlanes      = 1;
m_BitmapHdr.bih.biBitCount    = 8;
m_BitmapHdr.bih.biCompression = BI_RGB;
m_BitmapHdr.bih.biClrUsed     = 16;


从中可以看到显示在图形查看器中的位图 宽128个像素,高256个像素,用8位表示一种颜色(也就是256×××),实际使用了16种颜色(也就是调色板有16种颜色)。


再继续之前,先插入些和NES有关的小知识。NES文件中有两个调色盘(镜像什么的暂时不考虑),分别是背景调色盘和精灵调色盘。两个调色盘各占用16字节的大小,每个字节是一个索引,代表了256色中的一色。


好了继续。Create函数快结束的地方,有一句代码。

DirectDraw.GetPaletteData( m_Palette );


m_Palette是一个字节数组, 大小为256 * 4个字节,正好能表示256种颜色。再加上这个变量的命名,我猜测这句代码的功能是将被索引的256个颜色保存在m_Palette中。


差不多该研究一下OnTimer函数了。(函数代码一起贴出来比较乱,所以分开来贴)

LPBYTE  pPAL = (m_SelectPal<4)?&BGPAL[m_SelectPal*4]:&SPPAL[(m_SelectPal&3)*4];
    m_BitmapHdr.rgb[0] = m_Palette[pPAL[0]];
    m_BitmapHdr.rgb[1] = m_Palette[pPAL[1]];
    m_BitmapHdr.rgb[2] = m_Palette[pPAL[2]];
    m_BitmapHdr.rgb[3] = m_Palette[pPAL[3]];

m_SelectPal是一个0-7的整数,鼠标左键点击后会加1。这也就是为什么点击图形查看器,上面的画面会改变。

1-2行代码的作用就是依次从BGPAL(背景调色板)或SPPAL(精灵调色板)中取出4个颜色索引值。

3-6行代码可以看出图形查看器的每一幅画面其实只有4种颜色。


m_lpPattern是CPatternView的成员变量。保存的是待显示位图的像素数据。以下代码是m_lpPattern的赋值:

for( INT i = 0; i < 8; i++ ) {
        if( m_lpBank[i] != PPU_MEM_BANK[i] || PPU_MEM_TYPE[i] == BANKTYPE_CRAM ) {
            m_lpBank[i] = PPU_MEM_BANK[i];
            LPBYTE  lpPtn = PPU_MEM_BANK[i];
            for( INT p = 0; p < 64; p++ ) {
                LPBYTE  lpScn = &m_lpPattern[i*32*128+(p&15)*8+(p/16)*8*128];
                for( INT y = 0; y < 8; y++ ) {
                    BYTE    chr_l = lpPtn[y];
                    BYTE    chr_h = lpPtn[y+8];
                    lpScn[0] = ((chr_h>>6)&2)|((chr_l>>7)&1);
                    lpScn[4] = ((chr_h>>2)&2)|((chr_l>>3)&1);
                    lpScn[1] = ((chr_h>>5)&2)|((chr_l>>6)&1);
                    lpScn[5] = ((chr_h>>1)&2)|((chr_l>>2)&1);
                    lpScn[2] = ((chr_h>>4)&2)|((chr_l>>5)&1);
                    lpScn[6] = ((chr_h>>0)&2)|((chr_l>>1)&1);
                    lpScn[3] = ((chr_h>>3)&2)|((chr_l>>4)&1);
                    lpScn[7] = ((chr_h<<1)&2)|((chr_l>>0)&1);
                    // Next line
                    lpScn+=128;
                }
                // Next pattern
                lpPtn+=16;
            }
        }
    }


PPU_MEM_BANK是一个长度为12的指针数组。前8个指针指向图案表,后4个指向命名表或属性表。每个指针指向的空间大小为1K。

想深入了解图案表、命名表或和属性表的话,可以下载下面这个文档,看图形处理器一章。

http://down.51cto.com/data/951473



下面先讲讲我对于图案表、命名表和属性表的理解。


NES的游戏画面其实是由32*30个Tile组成的,每个Tile有8*8个像素。NES的画面只有16种颜色,因此只需要4位就可以表示一个像素。

那么每个Tile中8*8的像素是如何求得的呢?


命名表中保存了Tile的编号,Tile储存在图案表里。一张图案表里有256个Tile,因此寻址一个Tile要一个字节。一张命名表的大小可以算出来了,1字节*32*30=960字节。


图案表中真正保存的是Tile中的像素的低2位。上面还提到1个图案表保存了256个Tile,1个Tile有8*8个像素。那么一个图案表的大小是2位*256*8*8=32768位=4096字节=4kb

图案表储存一个Tile用16个字节,其中1、9字节用来求Tile的第1行像素,2、10字节用来求第二行像素,依次类推。具体计算方式比如,第1行像素的第一个,高1位是第9字节的最高位,低1位是第1字节的最高位。也就是说后八个字节共64位是64个像素的高位,前八个字节共64位是64个像素的低位。假设第1个字节是01010011,第9个字节是10101111,那么第一行像素就是2 1 2 1 2 2 3 3。


属性表比较小。1024字节的空间,命名表占用了960字节,剩下64字节就留给属性表了,属性表和命名表是配对出现的。属性表的一个字节保存的是4*4个Tile高2位的像素。计算一下大小32*32/4/4=64字节。


不得不说,这看似别扭的处理方式,在当时那种硬件资源缺乏的年代,还是挺不错的。


好了,继续回到我们的代码。

这是OnTimer函数最重要的一部分代码(为了方便查看,再贴一次)。

for( INT i = 0; i < 8; i++ ) {
        if( m_lpBank[i] != PPU_MEM_BANK[i] || PPU_MEM_TYPE[i] == BANKTYPE_CRAM ) {
            m_lpBank[i] = PPU_MEM_BANK[i];
            LPBYTE  lpPtn = PPU_MEM_BANK[i];
            for( INT p = 0; p < 64; p++ ) {
                LPBYTE  lpScn = &m_lpPattern[i*32*128+(p&15)*8+(p/16)*8*128];
                for( INT y = 0; y < 8; y++ ) {
                    BYTE    chr_l = lpPtn[y]; 
                    BYTE    chr_h = lpPtn[y+8];
                    lpScn[0] = ((chr_h>>6)&2)|((chr_l>>7)&1);
                    lpScn[4] = ((chr_h>>2)&2)|((chr_l>>3)&1);
                    lpScn[1] = ((chr_h>>5)&2)|((chr_l>>6)&1);
                    lpScn[5] = ((chr_h>>1)&2)|((chr_l>>2)&1);
                    lpScn[2] = ((chr_h>>4)&2)|((chr_l>>5)&1);
                    lpScn[6] = ((chr_h>>0)&2)|((chr_l>>1)&1);
                    lpScn[3] = ((chr_h>>3)&2)|((chr_l>>4)&1);
                    lpScn[7] = ((chr_h<<1)&2)|((chr_l>>0)&1);
                    // Next line
                    lpScn+=128;
                }
                // Next pattern
                lpPtn+=16;
            }
        }
    }


有以上基础,看这段代码就简单了。

代码里有3个for循环。每个for循环的循环次数我看了很久才明白。第1行的8是这么出来的,在NES中有2个图案表,第1个是背景图案表,第2个是精灵图案表。代码的作者还进一步把一个图案表分成了4份,为什么这样,我也不清楚,至少在我看的资料里还没有说把1个图案表分成4份的,总之4*2,8就出来了。

第2,3行的判断是什么意思,我还不明白。在我调试程序的时候,这个判断基本都是为true。

第5行在读取图案表的1/4部分

第6行的循环此处64比较明确,1个图案表有256个tile,四分之一的图案表就是64个tile。可以知道这个循环是在画tile。可以试着把64改成1,重新编译程序,运行,就可以看到1个tile是什么样的了。

第9行的循环次数是8,1次循环是在画1行像素。有了上文对图案表的讲解,具体1个像素是怎么求出来的应该已经可以理解了。

有一点我还想说一下,代码的原作者对12-19行的代码语句的排序实在是干扰了我很久,我一直以为这样的排序是有什么具体含义的,要是写成下面这样的话,规律就很明显了。

lpScn[0] = ((chr_h>>6)&2)|((chr_l>>7)&1);
lpScn[1] = ((chr_h>>5)&2)|((chr_l>>6)&1);
lpScn[2] = ((chr_h>>4)&2)|((chr_l>>5)&1);
lpScn[3] = ((chr_h>>3)&2)|((chr_l>>4)&1);                  
lpScn[4] = ((chr_h>>2)&2)|((chr_l>>3)&1);
lpScn[5] = ((chr_h>>1)&2)|((chr_l>>2)&1);
lpScn[6] = ((chr_h>>0)&2)|((chr_l>>1)&1);
lpScn[7] = ((chr_h<<1)&2)|((chr_l>>0)&1);


第21行的意思再解释一下。tile是正方形的,画完一行8个像素之后,画下一行前要先给lpScn加上图像的宽度128。

第24行比较简单,图案表的1个tile已经读取完了,tile的大小是16字节,为了读取下一个tile,就得给图案表指针lpPtn加上16。



好了图像查看器模块的代码差不多就说到这了,下一节讲解卷轴查看器的代码。卷轴就是背景,算是趁热打铁吧,学习了图案表、命名表、属性表,看看背景是如何根据这三张表得到的。