大富翁 3 游戏文件分析(十)

我终于搞懂了 PVS 的大致结构,让我们顺着上次的分析继续

基础知识

要看懂 PVS 的绘制方式,需要一些 VGA 十六色四平面的基础知识,关于这些知识我阅读 了不少资料,但是因为我理解能力比较弱或者相关资料年代久远,能够查阅的内容有限而且 比较难懂,收获不是很大,不过也不是没有收获,因为 vga 绘图这个话题很大,所以可能 查到的东西没办法跟我们的实用联系起来,所以挫败感很大。后来我发现,看懂 PVS 这块, 这一篇就足够了,当然 这篇我也没有完全看明白,不过基本够用,总结一下相关知识大概如下:

四平面

16 色 VGA 显示被切分为四个平面($2^4$),每个点的色彩索引被分在四个平面上,这样, 平面中的一个字节代表了 8 个像素的颜色信息,所以 80 个字节正好是 640 个点,对应 屏幕宽度,像素的位置按位从高到低排列,比如参考文中的例子,如果画一条 (2,0) 到 (6, 0) 的线,那么第一个字节为 0011 1110 也就是 3e,当然前提是该平面有这条线, 如果没有,那么这个字节为 0000 000000

如果颜色索引是 10(1010),由于平面是从低位开始,所以四个平面是这样:

Plane 0: 00000000 -> 00 
Plane 1: 00111110 -> 3e
Plane 2: 00000000 -> 00
Plane 3: 00111110 -> 3e

访问平面

每个平面的内存为 64k,但我们没办法直接访问,显卡只开放了 a000:0 开始的 64k 地址 给我们访问,所以如果要写入不同的平面,我们需要使用显卡的一些端口和寄存器来选择 要操作的平面,然后再执行操作,这样写入 a000:0 时,才会操作到对应的平面,访问平面 有四种写模式,两种读模式。很复杂对吧,不过别担心,接下来我们用到的,只有这些:

指令操作

  1. 指定端口和寄存器,例如 out 3ce, 05
  2. 写入数据,例如 out 3cf, xx

简化指令

指令 说明
3ce xx05 写入模式xx
3c4 xx02 按位选择平面xx

基础小结

其实前面我们就聊过四平面的设计,现在我们知道了,这个不是大宇的构思,是因为这样 比较方便写入显存。那么 VGA 16 色为什么采用四平面的设计,是因为这是一种向下兼容的 模式,比如,如果后面要改成 32 色,只要多加一个平面就好,而且缺失一个平面也不会 影响低端显卡的显示,不过这种设计显然没有考虑到显卡的发展速度,16 色接下来不是 32 而是 256 色,再接下来就是 16 位和 32 位色了

分析

之前我们提到过,程序是以某种方式把 ds:dx 的数据搬运到 a000:0 来绘图的,那么 我们可以把 dosbox 执行的汇编指令 dump 下来,看看是怎么做的。

我们知道如果要向 a000:0 写入数据,ES 要设为 A000 (参考Understanding the DOSBox debug screen ), 这样我们很快就可以从 log 定位到绘图代码的起点:

02B7:00000ECF  xor  di,di           ; di = 0                                             
02B7:00000ED1  lodsb                ; al = [ds:si][6b83:0000] = 5
                                    ; si = 0001 // si++                                  
02B7:00000ED2  movzx bx,al          ; bx = al = 5;                                  
02B7:00000ED5  shl  bx,1            ; bx << 1 // bx = 000a                                  
02B7:00000ED7  jmp  near word cs:[bx+0E8C]     cs:[0E96]=0F3D         

右边是伪代码注释,这段代码简单说就是读取一个字节(5),然后跳到 case 5, 那么我们继续看下去,case 5 很长,我们分解一下:

02B7:00000F3D  mov  ax,0005                                           
02B7:00000F40  mov  dx,03CE                                           
02B7:00000F43  out  dx,ax           ; out 03ce,0005 写入模式 0

设置写入模式 0,我们不需要关心写入模式是什么,往下看就好:

02B7:00000F44  lodsb                ; al = [6b83:0001] = 0d; si = 0002;                                   
02B7:00000F45  movzx cx,al          ; cx = al = 000d;

读取一个字节(13),然后设为次数

02B7:00000F48  mov  dx,03C4                                            
02B7:00000F4B  mov  al,02                                              
02B7:00000F4D  out  dx,al           ; out 03c4,02 选择平面索引                                  
02B7:00000F4E  inc  dx              ; dx = 03c5;                                   
02B7:00000F4F  mov  al,01           ; al = 01                                   
02B7:00000F51  mov  bx,di           ; bx = di = 0;                                   
02B7:00000F53  out  dx,al           ; out 03c5, 01 选择平面 0   

选择平面 0(1 = 0001),后续会向平面 0 写入数据,还有一个细节, bx 被设定为 ES 的当前位置 DI

02B7:00000F54  mov  di,bx           ; di = bx = 0;
02B7:00000F56  push cx              ; cx = 000d = 1101b
02B7:00000F57  push cx              
02B7:00000F58  shr  cx,02           ; cx >> 2; cx = 11b = 0003
02B7:00000F5B  repe movsd           ; copy(ds:si, es:di, 4) 
                                    ; [ES:DI][A000:0000] = [DS:SI][6B83:0002]
                                    ; di += 4; si += 4
                                    ; repeat 3 times;                                  
02B7:00000F5E  pop  cx              ; cx = 000d
02B7:00000F5F  and  cx,0003         ; cx = 1101b & 0011b = 1
02B7:00000F62  repe movsb           ; copy(ds:si, es:di, 1)
                                    ; [ES:DI][A000:000C] = [DS:SI][6B83:000E] 
                                    ; di += 1; si += 1
                                    ; repeat 1 times

有点啰嗦的操作:

  1. 设定初始位置为 bxES 的当前位置)
  2. DS 复制 4 个字节到 ES,重复 3
  3. DS 复制 1 个字节到 ES,重复 1

这可能是优化后的代码,合并一下逻辑:

  1. 设定初始位置为 bxES 的当前位置)
  2. DS 复制 13 个字节到 ES

这样我们知道了 case 5 读取的第一个字节的意思,复制的个数,接下来:

02B7:00000F64  pop  cx              ; cx = 000d
02B7:00000F65  add  al,al           ; al = 2;
02B7:00000F67  out  dx,al           ; out 03c5, 02

选择平面 1(2 = 0010)

02B7:00000F68  mov  di,bx           ; di = bx = 0
02B7:00000F6A  push cx              ; cx = 000d
02B7:00000F6B  push cx              ;
02B7:00000F6C  shr  cx,02           ; cx = 1101b >> 2 = 3
02B7:00000F6F  repe movsd           ; copy(ds:si, es:di, 4)
                                    ; [ES:DI][A000:0000] = [DS:SI][6B83:000F]
                                    ; di += 4; si += 4
                                    ; repeat 3 times
02B7:00000F72  pop  cx              ; cx = 000d
02B7:00000F73  and  cx,0003         ; cx = 000d & 0003 = 1
02B7:00000F76  repe movsb           ; copy 1 byte for 1 times
  1. 设定初始位置为 bxES 的当前位置)
  2. DS 复制 13 个字节到 ES

已经看到相同的逻辑了对吧,平面 23 也是如此:

02B7:00000F78  pop  cx              ; cx = 000d
02B7:00000F79  add  al,al           ; al += al = 4
02B7:00000F7B  out  dx,al           ; out 3c5, 4 : select plane 2
02B7:00000F7C  mov  di,bx           ; di = 0
02B7:00000F7E  push cx              
02B7:00000F7F  push cx              
02B7:00000F80  shr  cx,02           
02B7:00000F83  repe movsd           
02B7:00000F86  pop  cx              
02B7:00000F87  and  cx,0003         
02B7:00000F8A  repe movsb           
02B7:00000F8C  pop  cx              ; cx = 000d
02B7:00000F8D  add  al,al           ; al += al = 8
02B7:00000F8F  out  dx,al           ; out 3c5, 8 : select plane 3
02B7:00000F90  mov  di,bx           ; di = 0
02B7:00000F92  push cx              
02B7:00000F93  push cx              
02B7:00000F94  shr  cx,02           
02B7:00000F97  repe movsd           
02B7:00000F9A  pop  cx              
02B7:00000F9B  and  cx,0003         
02B7:00000F9E  repe movsb           

接下来是收尾部分:

02B7:00000FA0  pop  cx              ; cx = 000d
02B7:00000FA1  add  al,al           ; al += al = 0010
02B7:00000FA3  mov  ah,0F           ; ax = 0f10
02B7:00000FA5  mov  al,02           ; ax = 0f02
02B7:00000FA7  mov  dx,03C4         ; dx = 3c4
02B7:00000FAA  out  dx,ax           ; out 3c4, 0f02 : select all planes
02B7:00000FAB  lodsb                ; al = [ds:si][6b83:0036] = 09
02B7:00000FAC  movzx bx,al          ; bx = 09
02B7:00000FAF  shl  bx,1            ; bx << 1 : bx = 0012
02B7:00000FB1  jmp  near word cs:[bx+0E8C]     ; jump to 109d

收尾做了如下事情:

  1. 选择所有平面
  2. 读取一个字节(09)
  3. 执行 case 9
02B7:0000109D  mov  ah,00           ; ax = 0009 (al: case 09)
02B7:0000109F  shl  ah,03           ; ax = 0009
02B7:000010A2  or   ah,01           ; ax = 0109
02B7:000010A5  mov  dx,03CE         
02B7:000010A8  mov  al,05           
02B7:000010AA  out  dx,ax           ; out 3ce, 0105: write mode 1
02B7:000010AB  mov  bx,di           ; bx = di = 000d(save di to bx)
02B7:000010AD  movzx ax,[si]        ; ax = 0004
02B7:000010B0  sub  bx,ax           ; bx -= ax = 0009
02B7:000010B2  inc  si              ; si += 1 = 0038
02B7:000010B3  mov  al,es:[bx]      ; al = [a000:0009] = e8
02B7:000010B6  stosb                ; store al to es:di [a000:000d]
02B7:000010B7  lodsb                ; al = [ds:si][6b83:0038] = 05
02B7:000010B8  movzx bx,al          
02B7:000010BB  shl  bx,1            
02B7:000010BD  jmp  near word cs:[bx+0E8C]     ; jump to case 05

case 9 比较简单:

  1. 设置写入模式 1
  2. 保存 dibx(000d)
  3. 读取一个字节(04)
  4. bx 减去 04 (0009)
  5. si1
  6. es:bx 读取数据放入 al(e8)
  7. al 保存在 es:di
  8. 读一个字节(5),执行 case 5

很啰嗦对不对,重新理一下逻辑:

  1. 读取一个字节作为偏移量 offset
  2. es:(di - offset) 读取一个字节放入 es:di
  3. 读一个字节(5),执行 case 5

下一个又是 case 5 了,不过我没有打算继续了,如果你是我这个系列的「忠实读者」, 你可能和我会有相同的想法:感觉和前景、背景的绘图操作差不多,这时可以看一下 这里

和背景图 case 5case 9 的处理方式完全一样

结论

如果一个 case 的处理方式相同是巧合,那么两个 case 相同,我想我们可以试一下之前 的代码了,篇幅已经很长了,我们看个截图休息一下吧:

msb