第一 ,
第二 ,
第三部分 。
机器的其余部分
我们编写的用于模拟8080处理器的代码非常通用,可以很容易地使其适应于使用C编译器的任何计算机上运行,但是为了玩游戏本身,我们需要做更多的事情。 我们将必须模拟整个街机的设备,并编写将我们的计算环境的特定功能粘贴到模拟器的代码。
(您可能会对查看机器的
电路图感兴趣。)
时机
游戏运行在2 MHz 8080上。您的计算机速度更快。 为了考虑到这一点,我们将不得不提出某种机制。
打断
中断的设计使处理器能够以精确的执行时间处理任务,例如I / O。 处理器可以执行程序,并在触发中断引脚时停止执行当前程序并执行其他操作。
我们需要模拟街机生成中断的方式。
图形
Space Invaders在0x2400地址范围内将图形绘制到其内存中。 真正的硬件视频控制器将读取RAM并控制CRT显示。 我们的程序必须通过在窗口中渲染游戏图片来模仿这种行为。
按键
游戏中有一些物理按钮,程序可以使用8080处理器的IN命令读取这些按钮,我们的仿真器需要将键盘输入绑定到这些IN命令。
ROM和RAM
我必须承认:我们通过创建一个16 KB的内存缓冲区来“偷工减料”,其中包括低16 KB的处理器内存分配。 实际上,内存分配的前2 KB是实际的只读内存(ROM)。 我们将需要将内存中的写操作放入一个函数中,这样就不可能写到ROM。
音效
到目前为止,我们还没有说出任何声音。 Space Invaders具有可爱的模拟声音方案,可再现由OUT命令控制的8种声音之一,并传输到端口之一。 为了在我们的平台上播放声音样本,我们将必须转换这些OUT命令。
这似乎是一项艰巨的工作,但还不错,我们可以逐步进行。 我们要做的第一件事是查看屏幕,为此我们需要中断,图形以及IN和OUT命令的部分处理。
显示和更新
基础知识
您可能熟悉视频显示系统的组件。 系统中的某处有某种RAM,其中包含要在屏幕上显示的图像。 对于模拟设备,有些设备会读取此RAM,并将字节转换为传输到监视器的模拟电压。
对系统的深入了解将有助于我们分析内存分配和代码功能的目的。
模拟显示器对刷新率和时序有要求。 在任何给定时间,显示器都有更新的特定像素。 传输到屏幕的图像从左上角到右上角逐点填充,然后是第二行的第一个点,第二行的最后一个点,依此类推。 在屏幕上绘制最后一行之后,视频控制器可以生成垂直空白中断(也称为VBI或VBL)。
为确保动画流畅,视频控制器处理的RAM中的图像无法更改。 如果RAM更新发生在帧的中间,则观看者将看到两张图像的一部分。 当与底部框架不同的框架显示在屏幕顶部时,将导致“撕裂”效果。 如果您曾经看过换行符,那么您会知道它的外观。
为避免出现间隙,软件必须采取措施避免转移屏幕更新的位置。 而且只有一种方法可以做到这一点。
VBL是在最后一行的结尾之后生成的,通常在重画第一行之前需要一定的时间。 (这是垂直空白时间,大约为1毫秒。)
收到VBL时,程序将从上方开始渲染屏幕。
在进行帧扫描反向处理之前绘制每条线。
CPU总是在回热之前,因此可以避免断线。
太空入侵者视频系统
内容丰富的
网页告诉我们,太空侵略者有两个视频中断。 一个用于帧的结尾,但它也在屏幕中间产生一个中断。 该页面描述了屏幕更新系统-游戏在屏幕中间收到中断时在屏幕的上半部分绘制图形,而在帧末尾收到中断时在屏幕的下部绘制图形。 这是消除换行的一种非常聪明的方法,并且是当您同时开发硬件和软件时可以实现的良好示例。
我们必须强制我们的机器仿真以生成此类中断。 如果我们以60 Hz的频率以及Space Invaders机器生成它们,则将以正确的频率绘制游戏。
在下一节中,我们将讨论中断的机制并考虑如何模拟它们。
按钮和端口
8080使用IN和OUT指令实现I / O。 它具有8个独立的IN和OUT端口-该端口由命令的数据字节确定。 例如,
IN 3
将端口3的值放入寄存器A,
OUT 2
将A发送到端口2。
我从
计算机考古学网站上获取了有关每个端口用途的信息。 如果此信息不可用,我们将必须通过研究电路图以及阅读和逐步执行代码来获取。
:
1
0 (0, )
1 Start
2 Start
3 ?
4
5
6
7 ?
2
0,1 DIP- (0:3,1:4,2:5,3:6)
2 ""
3 DIP- , 1:1000,0:1500
4
5
6
7 DIP-, 1:,0:
3
2 ( 0,1,2)
3
4
5
6 "" ? , ,
(0=a,1=b,2=c ..)
( 3,5,6 1=$01 2=$00
, (attract mode))
有三种方法可以在我们的软件堆栈中实现I / O(由8080仿真器,机器代码和平台代码组成)。
- 将机器知识嵌入我们的8080仿真器
- 在机器代码中嵌入8080仿真器知识
- 在代码的三个部分之间创建一个正式接口,以通过API进行信息交换
我排除了第一个选项-很明显,仿真器位于此调用链的最底部,应该分开放置。 (想象一下,您需要将模拟器重新用于另一款游戏,您就会明白我的意思了。)在一般情况下,将高级数据结构传输到较低级别是一种较差的体系结构解决方案。
我选择了选项2。让我先显示代码:
while (!done) { uint8_t opcode = state->memory[state->pc]; if (*opcode == 0xdb)
该代码在同一层中重新实现了针对IN和OUT的操作码的处理,这将为其余命令调用仿真器。 我认为,这使代码更整洁。 这类似于两个命令的覆盖或子类,后者是指自动机层。
缺点是我们在两个地方转移了操作码的仿真。 我不会怪您选择第三个选项。 在第二个选项中,需要的代码更少,但是选项3更“干净”,但是价格却增加了复杂性。 这是样式选择的问题。
移位寄存器
太空侵略者机器有一个有趣的硬件解决方案,它实现了移位命令。 8080具有1位移位的命令,但将需要数十个8080组来实现多位/多字节移位,特殊的硬件允许游戏仅用少量指令即可执行这些操作。 在它的帮助下,每一帧都绘制在游戏场上,也就是说,每帧使用了多次。
我认为我无法比
对计算机考古学的出色
分析更好地解释它:
; 16- :
; f 0
; xxxxxxxxyyyyyyyy
;
; 4 x y, x, :
; $0000,
; write $aa -> $aa00,
; write $ff -> $ffaa,
; write $12 -> $12ff, ..
;
; 2 ( 0,1,2) 8- , :
; offset 0:
; rrrrrrrr result=xxxxxxxx
; xxxxxxxxyyyyyyyy
;
; offset 2:
; rrrrrrrr result=xxxxxxyy
; xxxxxxxxyyyyyyyy
;
; offset 7:
; rrrrrrrr result=xyyyyyyy
; xxxxxxxxyyyyyyyy
;
; 3 .
对于OUT命令,写入端口2设置移位量,写入端口4设置移位寄存器中的数据。 用IN 3读取将返回移位量后的数据。 在我的机器中,这是这样实现的:
-(uint8_t) MachineIN(uint8_t port) { uint8_t a; switch(port) { case 3: { uint16_t v = (shift1<<8) | shift0; a = ((v >> (8-shift_offset)) & 0xff); } break; } return a; } -(void) MachineOUT(uint8_t port, uint8_t value) { switch(port) { case 2: shift_offset = value & 0x7; break; case 4: shift0 = shift1; shift1 = value; break; } }
琴键
为了获得机器的响应,我们需要将键盘输入绑定到它。 大多数平台都有一种方法来接收击键和释放事件。 按钮的平台代码如下所示:
if(PeekMessage(&msg,NULL,0,0,PM_REMOVE)) { if (msg.message==WM_KEYDOWN ) { if ( msg.wParam == VK_LEFT ) MachineKeyDown(LEFT); } else if (msg.message==WM_KEYUP ) { if ( msg.wParam == VK_LEFT ) MachineKeyUp(LEFT); } }
将平台代码粘贴到仿真器代码的机器代码将如下所示:
MachineKeyDown(char key) { switch(key) { case LEFT: port[1] |= 0x20;
如果您愿意,可以根据需要组合机器和平台的代码-这是实现的选择。 我不会这样做,因为我要将机器移植到几个不同的平台。
打断
研究了手册之后,我意识到8080可以按以下方式处理中断:
- 中断源(CPU外部)设置CPU中断引脚。
- 当CPU确认接收到中断时,中断源可以将任何操作码发送到总线,而CPU可以看到它。 (大多数情况下,他们使用RST命令。)
- CPU执行该命令。 如果它是RST,则这类似于内存底部固定地址的CALL命令。 它将当前PC推入堆栈。
- 低位存储器地址中的代码处理中断要告诉程序的内容。 处理完成后,RST通过调用RET终止。
游戏的视频设备会产生两个我们必须以编程方式模拟的中断:帧的结尾和帧的中间。 两者均以60 Hz(每秒60次)执行。 1/60秒是16.6667毫秒。
为了简化处理中断的过程,我将向8080仿真器添加一个函数:
void GenerateInterrupt(State8080* state, int interrupt_num) {
平台代码必须实现一个我们可以调用的计时器(目前,我仅将其称为time())。 机器代码将使用它来将中断传递给8080仿真器。 在机器代码中,当计时器到期时,我将调用GenerateInterrupt:
while (!done) { Emulate8080Op(state); if ( time() - lastInterrupt > 1.0/60.0)
关于8080如何实际处理中断的一些详细信息,我们将不进行仿真。 我相信这种处理足以满足我们的目的。