2013年9月8日星期日

移植NT4的扫雷源码到Win1.x/2.x,加上一些对Win1/Win2的看法

这几天看到了Nathan Lineback的新文章(http://toastytech.com/guis/win1x2x.html),他把Winemine给移植到了Windows 1.x/2.x上。我有一份当初2004年Windows源码泄露事件中出现的微软版扫雷源码,于是也试了试。
我使用的平台是Microsoft C 5.1+Windows 2.03 SDK。这个平台比起以前那个不完整版的MSC 4.0+Windows 1.03 SDK来,至少能基本支持标准ANSI C语法了。
首先是一些基本的改动,比如和Windows源代码中的那个port1632.h相关的东西一定要先改过来。这些东西,自己玩过那套源码的都应该知道。
至于Makefile,可以不用自己写,原来那个makefile.dos可以稍微改一下来用,要把生成EXE的语句放到最后面,然后编译器相关的语句挪到最前面;相应的链接器要改成link4,库名也要改。
然后就是资源问题。Winmine的位图资源读取方法不是通过常用的LoadBitmap,而是用一套LoadResource/LockResource/SetDIBitsToDevice直接处理资源里的数据。Winmine自身带有黑白、彩色两套位图资源,代码里有相应的两条路径,需要通过仔细阅读所有C源程序文件,将其简化为一套代码。这个这里就不多提了。
Windows 1.x/2.x API不仅不支持DIB的直接处理,甚至其资源编译器所支持的BMP文件格式都不一样。Windows 3.0至今的未压缩BMP文件格式,其文件起始部分会有一个14字节的文件头、40字节的信息头,如果文件为索引色格式(如2色、16色或者256色等),每种索引颜色会再使用4个字节。而Windows 1.x/2.x的BMP文件格式,到现在根本就没有任何软件能直接读写,甚至文件头都基本上是通过观察SDK里图标编辑器输出的文件而推测出来的(感谢John Elliott)。
这种BMP文件只支持黑白两色,其文件头长度16字节,第一个字节恒为02,第二个字节为00或者80(80代表DISCARDABLE也就是可以动态从内存中移出的),然后跟着一个这样的结构:
 typedef struct tagBITMAP {  
   short   bmType;  
   short   bmWidth;  
   short   bmHeight;  
   short   bmWidthBytes;  
   BYTE    bmPlanes;  
   BYTE    bmBitsPixel;  
   LPSTR   bmBits;  
 } BITMAP;   
(来自Windows 2.03的windows.h)

这里的short是16位整数,bmBits指针是32位长度,在这里恒为0。
文件头之后就是文件数据部分,据我的测试(通过制作Win3.0 单色BMP文件,去掉文件头之后加上自己拼出来的1.0/2.0文件头,再用图标编辑器打开看是否正常),发现它和Windows 3.0以上的单色位图(2种索引色)的数据格式并不一样,每行字节数不管怎么设置都无法显示正确的位图。情急之下,我问了Nathan Lineback,得知这种单色位图的数据部分和Paint Shop Pro 3.x/4.x版本输出的Sun raster格式(.ras)位图的数据部分基本相同,只是按位取反了而已。于是,下载了Paint Shop Pro 3.1.2,然后用它打开Winmine资源里的黑白位图,反色,另存为RAS文件;再去掉这些RAS文件的文件头(32个字节),加上自己拼出来的Win1.x BMP文件头,反复修改每行字节数,用图标编辑器反复测试打开(如果不想一直为了这个而跑Windows 1.x或者2.x,可以用Borland Resource Workshop 4.5把2.03 SDK的图标编辑器做资源转换后,在NTVDM上直接运行),最终就把三个黑白位图资源文件(Blocks、Buttons、LEDs)转换为了Windows 1.x的BMP格式。如图1、图2。


图1 Paint Shop Pro 3.12中处理后的RAS位图文件



图2 转换资源之后的图标编辑器,打开转换好的位图文件

至于图标文件,无关紧要,用图标编辑器先随手画一个用着算了。
资源文件转换好之后,要对rc文件也做相应的修改,不仅要修改其中用到的文件名、去掉不存在的头文件导入,还要把所有自带的导入文件的内容放到主rc文件里,因为Windows 2.03 SDK的资源编译器不支持导入文件中定义的字符串、对话框等;至于Windows 2.03不支持的一些东西,比如对话框的FONT关键词之类,可以到编译生成时根据错误信息修改。
之后就是改写Winmine的图形核心部分了。Winmine的图形核心是grafix.c,里面包括读取位图、绘制边框、绘制状态按钮、绘制雷区方块和绘制整个雷区等关键代码。
首先要改的就是读取位图函数。本函数把Winmine的三个位图,按钮、LED和雷区方块(Button、LED、Blocks)从资源中读入内存,并将这些大图按照格数、位图数据起始地址、每小块数据的字节数分成小块,对于常用的雷区方块,每个小块都专门给一个句柄。
一开始就是起始地址的计算。原版代码计算出的位图数据起始地址,是从LockResource的结果开始,加上BMP信息头的长度(Windows 3.0以上的资源编译器会去掉BMP文件的主文件头)和颜色索引使用的长度;而Windows 1.x的资源编译器,并不会对BMP的文件头作处理,而且文件头结构不同,因此要把这里修改掉。grafix.c里专门有一个函数来计算每小块数据的字节数,但对我们的bmp来说,计算出来的结果是错误的,因此这里不用,使用我们以(每行字节数*总行数)/格数计算出来的值(每行字节数、总行数可以直接从文件头的那个BITMAP结构中得到)。
然后往下看,就是把Blocks位图分块为多个位图的代码。这里用到的CreateCompatibleDC、CreateCompatibleBitmap都没有问题,问题在于后面用到了SetDIBitsToDevice,Win2.03没有,必须修改掉。这里我们使用SetBitmapBits来替代,这是一个和设备相关的函数,使用方法可以参考MSDN。这里需要特别注意的是,在Win1.x的BMP中,图像数据是从第一行从上往下存储的,而不是像后来的BMP那样从下往上存储。因此,图像小块的地址与序号的对应关系需要考虑清楚。
在下面的代码中,还有两处用SetDIBitsToDevice来画图的地方(画LED和画状态按钮)。因为这些函数就只传入一个hDC,我的改法是在这两个函数里新建一个临时位图句柄,然后先用SetBitmapBits把图像小块复制到临时位图里,再BitBlt到目标hDC。这里同样要注意图像小块的地址与序号的对应关系。
grafix模块的改动基本就这些,其他的文件根据编译错误与警告来做相应的修改(基本都是一些无关紧要的玩意,很容易改),都可以改到编译通过。编译设置是Small内存模型,此时不知是链接器还是资源编译器的问题,运行时无法加载资源并出错,因此只能在makefile.dos里把生成代码改为Medium内存模型(编译器参数的-AS改为-AM,两个库文件改为mlibw和mlibcew)后,才能运行成功。
在Windows 2.03上运行虽然没有报错,但是却发现,雷区是花版的,而且状态按钮边框显示明显有错误,如图3。

图3 显示有错误的界面

为什么会变成这样呢?仔细再查一下grafix.c。负责描画整个背景的函数是DrawBackground,状态按钮边框的问题就出在这里了。请看以下代码:
     DrawBorder(hDC, x =dxWindow-(dxRightTime+3*dxLed+dxpBorder+1), dyTopLed-1, x+(dxLed*3+1), y, 1, 0);  
     DrawBorder(hDC, x = ((dxWindow-dxButton)>>1)-1, dyTopLed-1, x+dxButton+1, dyTopLed+dyButton, 1, 2);  
这两行代码有一个很有趣的现象,前面参数中修改了变量x,在后面的参数中又马上引用了。C语言标准好像并没有规定在传参之前各参数表达式的执行顺序,如果这个顺序是从左到右,那么这两行的设计目的就能达到;但如果是从右到左,那么这段代码的效果就和设计意图不符了。干脆试着把这个对x赋值的表达式移到外面来,再次编译运行,状态按钮的边框居然正常了。
然后花屏问题呢?仔细检查所有相关代码,没有发现有逻辑问题。查了一下,查到微软知识库(http://support.microsoft.com/kb/160522)里说CreateCompatibleBitmap在NT和9x系统上的效果有差异,后者不会初始化生成的位图数据。于是,根据知识库的实例,在刚才说的Blocks位图分块代码中,CreateCompatibleBitmap、SelectObject之后,SetBitmapBits之前加入一句
                PatBlt(MemBlkDc[i], 0, 0, dxBlk, dyBlk, BLACKNESS);  
来初始化CreateCompatibleBitmap生成的位图数据。这样一来,雷区花版的问题应该解决了。但是编译运行之后,发现颜色有异。如图4:

图4 雷区颜色不正确

继续查。查MSDN的PatBlt,说BLACKNESS并不是指黑色,而是设备调色盘中的颜色0。但是不用PatBlt的话,我用了几种别的方法,什么FloodFill之类,但不管怎么去弄,最后前景还是红色的,看来问题不是出在这里了。就在一筹莫展之时,我看到了原有注释里有这样两句:
       //  
       // create the bitmap for the above memory DC and selct this bitmap.  
       //  
       // we really only need 1 bitmap and 1 dc as they have done before!  
       //  
对啊,为啥一定要把完整的Blocks位图分成n个呢?如果只做一个Blocks位图的HDC,需要时用BitBlt把所需部分直接贴到目标处不就行了么。
说到做到,写几行代码实现下。
      bminfo=(LPBITMAP)(lpDibBlks+2);  
     cb= ((bminfo->bmWidthBytes)*(bminfo->bmHeight))/iBlkMax;  
     hDC = GetDC(hwndMain);  
      hbBlksDC = CreateCompatibleDC(hDC);  
      hbBlks= CreateCompatibleBitmap(hbBlksDC, dxBlk, dyBlk*iBlkMax);  
      SelectObject(hbBlksDC, hbBlks);  
      SetBitmapBits( hbBlks,cb*iBlkMax, lpDibBlks+cbDibHeader);  
新加全局变量HDC hbBlksDC、HBITMAP hbBlks,然后通过这几行把整个Blocks位图变成了一个hDC。底下的那些分块处理的代码,就注释掉。
然后修改描画雷区方块的代码,有两三个函数里用到而且写法差不多,这里就列其中一个函数的改法。其中红字为新加上的代码,绿字为注释掉的原有代码,列出来如下:
 VOID DisplayBlk(INT x, INT y)  
 {  
     HDC hDC = GetDC(hwndMain);  
     BitBlt(hDC,  
         (x<<4)+(dxGridOff-dxBlk),  
         (y<<4)+(dyGridOff-dyBlk),  
         dxBlk,  
         dyBlk,  
        /* MemBlkDc[iBLK(x,y)],  
         0,  
         0,*/  
         hbBlksDC,  
         0,  
         16*(iBlkMax-1-iBLK(x,y)),  
         SRCCOPY);  
     ReleaseDC(hwndMain, hDC);  
 }  
其实很简单,就是把原来从Blocks分成的某一小块来BitBlt,变成从完整的Blocks位图里选出一部分来Bitblt。然后千万别忘了改释放位图的函数:
 VOID FreeBitmaps(VOID)  
 {  
     int i;  
     if (hGrayPen != NULL)  
         DeleteObject(hGrayPen);  
     UnlockResource(hResBlks);  
     UnlockResource(hResLed);  
     UnlockResource(hResButton);  
     /* for (i = 0 ; i < iBlkMax; i++) {  
       DeleteDC(MemBlkDc[i]);  
       DeleteObject(MemBlkBitmap[i]);  
     }*/  
           DeleteDC(hbBlksDC);  
           DeleteObject(hbBlks);  
 }  

编译运行测试,结果雷区的颜色居然正常了。可能是因为Win1.x/2.x没有DIB,调色盘处理有一点小缺陷吧。如图5。


图5 雷区颜色终于正常了

上面我们一直是在Windows 2.03下测试运行的,Windows 1.x下跑会怎么样呢?让我们来看看。程序在Windows 1.01下的运行状态如图6所示。

图6 Windows 1.01上运行的状态

可以看出,由于Windows 1.x限制普通窗口不可层叠,扫雷界面很难完整显示出来;而在Windows 1.01下能层叠显示的只有带WS_POPUP属性的窗口或对话框。我们找到主模块中的CreateWindow函数,修改一下主窗口的风格。
      {  
      DWORD style = WS_MINIMIZEBOX | WS_CAPTION | WS_SYSMENU;  
      if (LOBYTE(GetVersion())>1)   
           style = style | WS_OVERLAPPED;  
      else  
           style = style | WS_POPUP;  
      hwndMain = CreateWindow(szClass, szWindowTitle,  
         /* WS_OVERLAPPED | WS_MINIMIZEBOX | WS_CAPTION | WS_SYSMENU*/style,  
           Preferences.xWindow-dxpBorder, Preferences.yWindow-dypAdjust,  
           dxWindow+dxpBorder, dyWindow +dypAdjust,  
           NULL, NULL, hInst, NULL);  
      }  
这里用了一个临时变量,首先判断系统版本,然后动态设定窗口风格,因而能达到我们的目的。这样,在Windows 1.01下运行得到的就不是一个平铺的窗口而是一个层叠窗口了,唯一的问题是不能最小化和后台运行,因为WS_POPUP风格的窗口在Windows 1.01下总是出现在前面的。如图7。

图7 Windows 1.01上主窗口以WS_POPUP形式显示

至此,主要的移植任务已经差不多了。虽然还有一些严重的bug,比如程序稳定性还有点小问题,而且在Win1.01下,一旦死掉或者胜利后重来,计时器就不动了,而2.03下就没有这个问题。另外,窗口位置调整函数等一些东西也需要修改,资源文件里也要为Windows 1.x做一套没有'&'字符的菜单定义,等以后有时间再来弄一下吧。

移植了这玩艺,我想对Windows 1.x/2.x这个被人彻底遗忘的平台说几句看法。首先,Windows API对其历史的重视和向下兼容是名不虚传的。我移植扫雷的整个过程中,几乎一直都在搜索在线版本的MSDN,发现Windows 2.03唯一的一个头文件windows.h里的很大一部分函数,在2013年的MSDN里都还能找到名字相同,参数可能有些差异但差异不大的函数;至于概念上的东西,比如消息循环、GDI对象架构之类,25年来更只是在实现上不断地改变。25年前学会Windows SDK编程的人,到现在来学习现在的Windows SDK开发,其学习曲线比起其他平台来说是要短很多的。其次,通过这一次的移植,我算是知道当年Windows 1.x/2.x的商业应用不超过两位数的原因了:一是硬件支持太有限,Windows 1.x只能在640K常规内存中运行,Windows 2.x虽然支持EMS,但是EMS同一时间只能访问其中一个64K大小的页面,这使得当时的Windows平台难以像其他可以直接访问大内存的平台那样进行复杂的图像处理等操作,吸引力大大降低;二是开发工具有限,开发者只能使用微软自己的编译器和CRT,并且需要自己编写Makefile(当然现在写DDK的也要这么做),而当时苹果Mac平台上除了苹果自己那个使用起来复杂困难的MPW环境外,已经出现了不少便利的第三方集成开发环境,比如Think C等。三是微软最初并没有把Windows放在其战略核心的位置上,这是因为,当时微软尚未完全预见到未来的走势,商业用户们还在沉醉于他们的DOS版Lotus-1-2-3和WordPerfect,觉得Mac都仍然只是昂贵的玩具,更别说去想那些Unix工作站上已经跑起来的东西的未来前景了。Windows 1.x/2.x的GUI,很大程度上是为了和苹果斗气而作出来的一个试验性产品,不少地方使用起来非常麻烦,其外壳程序MS-DOS Executive比起不少DOS下的文件管理器来说简直就是废物。当时,微软很大一部分的希望是寄托在和IBM合作开发的OS/2上。直到80年代末,微软与IBM的亲密合作关系开始出现裂痕,IBM这个靠山靠不住了,微软才真正决心把Windows做成他们的核心产品,先是为Mac平台上已经取得成功的Word、Excel制作了Windows版本,再是全力开发Windows 3.0,从此Windows才真正开始走向成功之路。
我认为,虽然Windows 1.x/2.x没有被广泛使用,但是Windows API 三十年的基业,也正是从这样一个简陋的环境开始一步一步走过来的。因此,这个环境对于我这样的一个古董软件爱好者来说,有很大的研究意义。

后记:也许因为我没有采用LoadBitmap来处理资源,这个程序竟然可以不加任何修改地在Windows 8 32位版的NTVDM中直接运行,如图8。但是我在Windows 3.0和3.1下尝试运行时,却出现了死机的现象,看来兼容性还是有些问题的。


图8 在32位Windows 8下运行我移植的扫雷




没有评论:

发表评论