VCMAME - 用于 Visual C++ 的多街机模拟器






4.89/5 (49投票s)
本文将解释 MAME 源代码树中的一些软件工程问题,并概述模拟器的工作原理。
目录
引言
1997 年 2 月 5 日,Nicola Salmoria 发布了 MAME(Multiple Arcade Machine Emulator)的第一个版本。它包含了源代码,允许用户在其 DOS PC 上玩包括 Pacman 和 Pengo 在内的 5 款不同的街机游戏。这不仅仅是重制,而是原始的街机代码在一个虚拟机中运行。自那时以来,MAME
吸引了一群核心开发者,他们利用业余时间为项目做出贡献,它模拟了超过 2300 款独特的街机游戏和超过 4000 种变体,并被移植到各种各样的平台,从现代 PC 到 PDA,再到 Unix 工作站,甚至包括索尼 PlayStation 2、世嘉 Dreamcast 和微软 Xbox 等封闭系统。令人惊叹的是,一些人甚至建造了街机机柜,并使用 MAME 来驱动它们,而不是使用原始的街机硬件。
本文将解释 MAME 源代码树中的一些软件工程问题,并概述模拟器的工作原理。其中讨论了一些更有趣的编程技术。本文应该能为任何考虑开发 MAME 的人提供一个良好的起点。它还介绍了 MAME 的变体 VCMAME
,这是一套项目文件和扩展,允许在 Microsoft Visual C 系列编译器下开发 MAME。
- MAME 网站:http://www.mame.net/
- VCMAME 网站:http://www.vcmame.net/
什么是 MAME?
MAME 模拟了经典街机游戏机硬件,使得旧的街机软件能够在现代计算机上运行。这样做的主要目的是记录硬件的工作方式,既是为了纯粹的教育目的,也是为了帮助修复经典的硬件。大多数经典的街机软件都受版权保护,MAME 的用户需要购买合法的许可证才能使用它,或者从原始街机主板等原始来源获取材料。少数原始 ROM 镜像已公开,可在 MAME 网站上下载。MAME 本身可以免费复制和分发。
MAME 架构
从软件工程的角度来看,MAME 可以分为三个代码“层”。顶层处理模拟特定游戏的细节。中间层包含通用函数和模块以及“粘合”代码,底层负责将模拟结果呈现给用户。这种布局风格是跨平台移植的基石。顶层和中间层包含纯 C 代码——没有 Windows 函数调用,没有 DirectX 调用——所有代码都是自包含的,可以在任何平台的任何 C 编译器上编译。所有平台相关的代码都保留在底层——这也就是您的 main
或 WinMain
的地方——这里会使用 DirectX 或 Linux 内核调用。在 MAME 术语中,这就是 OSD 层(操作系统相关)。这一层使得 MAME 的移植变得简单——如果您想将 MAME 移植到新平台,比如 PDA,您只需插入一组满足中间层接口要求的新 OSD 调用即可。无需更改中间层或游戏层。许多人可能已经熟悉这种风格,而且许多人,尤其是游戏程序员,会意识到随着时间的推移,平台无关层可能会“渗透”到其上层,破坏模块化并创建层之间的不必要耦合。通常,这是由程序员想要快速解决问题而不是找到干净的解决方案造成的。您见过多少次在所谓的跨平台项目中偷偷插入 #ifdef WIN32
?MAME 目前能很好地处理这个问题,在构成游戏层的约 1300 个文件中,没有任何平台特定的定义。在中间核心层中,目前存在少量平台/编译器特定的定义。
让我们来看看源文件的实际布局。
Src/ | 核心跨平台文件。 |
Src/cpu Src/sound | 核心 CPU 和声音芯片模拟。这些是跨平台的,并且由不同的机器模拟共享。 |
Src/drivers Src/vidhrdw Src/machine Src/sndhrdw | 这些是单独的“机器”模拟,换句话说,就是“游戏驱动程序”。将新游戏添加到 MAME 的过程通常涉及创建一组新的机器模拟文件。为了便于阅读,驱动程序的各个组件最多被分成 4 个文件,每个文件在不同目录中具有相同的名称。“driver”文件包含与硬件定义(ROM、内存映射、CPU、图形布局)相关的 C 代码和数据。“vidhrdw”文件封装了模拟机器视频硬件所需的功能。类似地,“sndhrdw”文件封装了与声音硬件相关的功能。“machine”文件包含任何剩余的内容,例如 NVRAM 支持或实时时钟。 |
源
MAME 源代码首先需要认识到的是,这是一个 C 项目,而不是 C++。尽管模拟,特别是多机器和多处理器模拟,似乎非常适合面向对象的设计,但 C++ 在 1996 年 MAME 初始开发开始时并没有广泛普及。C++ 还带有“比 C 慢”的污名,而这在当时是一个问题,因为即使是最简单的模拟也会将 486-DX 或 Pentium-66 推向极限。如今,大多数机器的模拟性能已不成问题,MAME 的政策始终是确保准确模拟而非性能,但没有计划将源代码树转换为 C++。相反,在多年的源代码重构过程中,引入了大量的宏使用,其中一些旨在模仿面向对象的一些特性。
让我们看看 MAME 的“驱动程序”是如何组合起来的;其中最重要的部分是“机器”的模拟。
机器定义与构建
“机器”定义如下:它有一个名称,至少有一个能够运行二进制程序的处理器,关于该处理器内存映射和中断的信息,关于该机器视频输出(分辨率、每秒帧数等)的信息,以及关于该机器声音输出(单声道、立体声等)的信息。来自 src/drivers/m90.c
static MACHINE_DRIVER_START( bombrman )
/* basic machine hardware */
MDRV_CPU_ADD(V30,32000000/4) /* NEC V30 CPU @ 8 MHz */
MDRV_CPU_MEMORY(readmem,writemem) /* Pointers to memory maps */
MDRV_CPU_PORTS(readport,writeport) /* Pointers to memory mapped ports */
MDRV_CPU_VBLANK_INT(m90_interrupt,1) /* Vertical blank callback function */
MDRV_CPU_ADD(Z80, 3579545) /* Zilog Z80 @ 3.579545 MHz */
MDRV_CPU_FLAGS(CPU_AUDIO_CPU) /* CPU handles audio only */
MDRV_CPU_MEMORY(sound_readmem,sound_writemem)
MDRV_CPU_PORTS(sound_readport,sound_writeport)
MDRV_CPU_VBLANK_INT(nmi_line_pulse,128)
MDRV_FRAMES_PER_SECOND(60) /* Video display runs at 60fps */
MDRV_VBLANK_DURATION(DEFAULT_60HZ_VBLANK_DURATION)
MDRV_MACHINE_INIT(m72_sound) /* Pointer to init function */
/* video hardware */
MDRV_VIDEO_ATTRIBUTES(VIDEO_TYPE_RASTER) /* Raster monitor */
MDRV_SCREEN_SIZE(64*8, 64*8) /* Video output size in pixels */
MDRV_VISIBLE_AREA(10*8, 50*8-1, 17*8, 47*8-1) /* Visible viewport */
MDRV_GFXDECODE(gfxdecodeinfo) /* Pointer to gfx decode information */
MDRV_PALETTE_LENGTH(512) /* Video palette length */
MDRV_VIDEO_START(m90) /* Pointer to video init function */
MDRV_VIDEO_UPDATE(m90) /* Pointer to main renderer function */
/* sound hardware */
MDRV_SOUND_ADD(YM2151, ym2151_interface) /* Yamaha 2151 soundchip */
MDRV_SOUND_ADD(DAC, dac_interface) /* DAC used for sound samples */
MACHINE_DRIVER_END
当宏被解开后,这归结为一个 static
函数,它“构建”一个可以被模拟子系统处理的 struct
。
// #define MACHINE_DRIVER_START(game)
void construct_##game(struct InternalMachineDriver *machine)
{
struct MachineCPU *cpu = NULL;
(void)cpu;
// #define MDRV_CPU_ADD_TAG(tag, type, clock)
cpu = machine_add_cpu(machine, (tag), CPU_##type, (clock));
// #define MDRV_CPU_ADD(type, clock)
MDRV_CPU_ADD_TAG(NULL, type, clock)
// #define MDRV_CPU_MEMORY(readmem, writemem)
if (cpu)
{
cpu->memory_read = (readmem);
cpu->memory_write = (writemem);
}
这种方法有一些有趣的副作用——机器可以共享资源,如内存映射和渲染器函数。机器也可以通过分层构造函数相互继承——假设我想为上述机器添加一个支持立体声音响的变体。
static MACHINE_DRIVER_START( bombrman_stereo )
MDRV_IMPORT_FROM(bombrman)
MDRV_SOUND_ATTRIBUTES(SOUND_SUPPORTS_STEREO)
MACHINE_DRIVER_END
宏首先构建基础机器,然后机器定义中的任何其他参数都会覆盖原始规格。
#define MDRV_IMPORT_FROM(game)
construct_##game(machine);
内存映射
让我们看看内存映射是如何定义的。
static MEMORY_WRITE_START( writemem )
{ 0x00000, 0x7ffff, MWA_ROM },
{ 0xa0000, 0xa3fff, MWA_RAM },
{ 0xd0000, 0xdffff, m90_video_w, &m90_video_data },
{ 0xe0000, 0xe03ff, paletteram_xBBBBBGGGGGRRRRR_w, &paletteram },
{ 0xffff0, 0xfffff, MWA_ROM },
MEMORY_END
内存映射的概念对大多数底层程序员来说很熟悉。在这里,很容易看到映射是如何被分成块的。从基础到 512KB(0x80000 字节)的范围,CPU 访问程序 ROM,就像 PC 中的 BIOS ROM 一样。从 0xa0000 到 0xa3fff 是通用 RAM(16KB)。从 0xd0000
到 0xdffff
是视频 RAM,就像 PC 中的 VGA RAM 一样。在这里,指定了一个回调函数 m90_video_w
——每当 CPU 向该内存区域写入数据时,就会执行此函数。这就是渲染器(视频处理器模拟)如何跟踪 CPU 发出的视频命令/数据的方式。还创建了一个指向原始内存块的指针——m90_video_data
。视频模拟可以通过它直接访问模拟的视频内存。类似地,在内存映射的 0xe0000 处,定义了一个内存区域作为调色板区域。在这里,指定了一个函数,该函数处理机器颜色信息(BBBBBGGGGGRRRRR 或 B5G5R5 颜色格式)到 MAME 内部使用的平台无关 24 位调色板系统的映射。同样,初始化了一个指向该内存块基址的指针供其他地方使用。内存映射的最后是另一个 ROM 区域,这次包含处理器启动向量。由于 NEC V30 处理器具有 20 位地址总线,您会注意到最高可寻址位置是 0xfffff
。您可能还会注意到有些内存块未列出——这些是未定义的——CPU 不会访问它们。
让我们看看组装内存映射的一些宏。
#define MEMORY_WRITE16_START(name) \
MEMPORT_ARRAY_START(Memory_WriteAddress16, name, \
MEMPORT_DIRECTION_WRITE | MEMPORT_TYPE_MEM | MEMPORT_WIDTH_16)
#define MEMORY_END MEMPORT_ARRAY_END
#define MEMPORT_ARRAY_START(t,n,f) const struct t n[] = \
{ { MEMPORT_MARKER, (f) },
#define MEMPORT_ARRAY_END { MEMPORT_MARKER, 0 } };
struct Memory_WriteAddress16
{
offs_t start, end; /* start, end addresses, inclusive */
mem_write16_handler handler; /* handler callback */
data16_t ** base; /* receives pointer to memory (optional) */
size_t * size; /* receives size of memory in bytes (optional) */
};
typedef write16_handler mem_write16_handler;
typedef void (*write16_handler)(UNUSEDARG offs_t offset,
UNUSEDARG data16_t data,
UNUSEDARG data16_t mem_mask);
哇,这真是一堆混乱的定义和类型定义!它基本上归结为一个 const
数组,包含 'memory_writeaddress16
' struct
,数组开头有一个特殊的虚拟 struct
带有标志,数组结尾有一个虚拟 struct
作为地图解析器的信号。但有两点很重要:struct
s 可以部分初始化的事实意味着驱动程序中的地图可以包含可选元素。如果地图需要初始化基本指针和大小变量,则可以指定它们。如果没有,则地图定义中没有混乱,它干净易读(以牺牲复杂的宏为代价)。另一件事是回调函数具有严格的类型安全——每种可能的类型(读或写)以及不同的数据总线宽度都有不同的回调原型。对于上面的 16 位回调函数,传入的参数是内存块内的偏移量、数据本身以及一个掩码参数(例如,对于仅写入一个字节的 16 位总线)。使用这些回调函数对驱动程序代码来说也很简单,例如,使用宏。
WRITE16_HANDLER( m90_video_w )
{
/* Code */
}
视频与音频
那么我们现在在哪里?我们已经大致展示了一个 MAME 机器驱动程序如何根据其使用的处理器和涉及的内存映射来定义。事实上,MAME 源代码树中目前定义了 1700 多个不同的机器。定义一台机器还需要什么?音频和视频。
声音和视频模拟是将运行 CPU 虚拟机产生的原始数据映射成适合在目标平台上输出的形式。从工程角度来看,这实际上分两个阶段进行——游戏驱动程序组装一个平台无关的视频帧和音频流,如果需要,则利用 MAME 核心引擎函数,然后将它们提供给核心,核心再将其传递给平台特定的后端代码。在 Windows 上,DirectX 用于向用户呈现视频和声音。
在上面的机器定义中,我们指定机器以 60fps 运行,并且我们指定了一个视频更新回调。这基本上意味着该回调每秒执行 60 次——此时,它必须基于模拟视频内存的当前状态提供一个视频帧。视频更新例程通常可以访问内存映射中定义的所有内存和变量。一个 update
函数可能定义为:
VIDEO_UPDATE( m90 )
{
fillbitmap(bitmap,Machine->pens[0],cliprect); /* Erase bitmap with pen 0 */
m90_drawsprites(bitmap,cliprect); /* Draw sprites onto bitmap */
}
是的,又一个宏。
#define VIDEO_UPDATE(name) \
void video_update_##name(struct mame_bitmap *bitmap, \
const struct rectangle *cliprect)
与读写内存处理程序一样,所有视频更新函数都有宏强制执行的隐式参数——要绘制的位图,以及必须遵守的剪辑区域。
MAME 中的每台机器通常都有自己的视频更新函数,因为很少有机器以相同的方式绘制其图形。
机器销毁
上面,我们讨论了如何使用宏来构建要运行的模拟机器。没有讨论的是销毁机器——事实上,这很容易处理。切换机器时,所有东西都会被销毁——用于构建机器以及由其相关驱动程序代码分配的所有分配都通过自动销毁分配器进行。所有分配都在内部记录,并在切换机器时通知分配器进行清理。通过这种方法,驱动程序代码中的内存泄漏是不可能的,因为 MAME 核心拥有所有分配。
VCMAME
来自 http://www.mame.net/ 的标准 MAME 源代码包目前打算使用 GCC 3.0 进行编译,正如您所料,它基于一系列 makefile 来控制编译和构建选项。VCMAME 是一套 Visual C 项目文件,允许使用 MS Visual C IDE/编译器构建 MAME。目前支持 VC6、VC.NET 2002 和 VC.NET 2003。
构建选项在 VC 项目设置和一个特殊的配置文件 /src/vc/vcmame.h 之间分配。vcmame.h 为项目中要包含的 CPU 和声音核心设置了项目范围的预处理器定义,并控制编译器警告级别。为了避免对 MAME 源代码进行大量更改,此文件会自动包含到所有其他源文件中,使用 VC 强制包含标志(/FI)。
有三种不同的项目配置——Debug、Release 和 Dev Release。前两种很大程度上是显而易见的,Debug 不仅包含调试信息和符号,还包含集成 MAME 调试器用于调试模拟目标。Release 版本是为了速度而构建的,并利用了各种编译器优化。Dev Release 是两者的混合体——它是为速度而构建的,因此经过优化,不包含程序调试信息,但包含集成 MAME 调试器。这在您实际上不调试 MAME 本身而是尝试调试模拟程序的情况下非常有用。在这种情况下,您通常希望模拟目标以全速运行,而在标准的 Debug 版本中这通常是不可能的。
出于性能原因,MAME 源代码树中包含了一些 x86 汇编代码。始终提供等效的 C 源代码,以便 MAME 可以在非 x86 平台上编译,但这不适用于 VCMAME,因为它仅限于 x86。VC 和 GCC 在处理内联汇编方面存在很大差异——GCC 使用 AT&T 汇编器语法,而 VC 使用 Intel 风格的汇编器。对于小块代码,使用 _MSC_VER
定义来同时提供两个版本,例如,来自 src/windows/osinline.h
#ifdef _MSC_VER
#define vec_mult _vec_mult
INLINE int _vec_mult(int x, int y)
{
int result;
__asm {
mov eax, x
imul y
mov result, edx
}
return result;
}
#else
#define vec_mult _vec_mult
INLINE int _vec_mult(int x, int y)
{
int result;
__asm__ (
"movl %1 , %0 ; "
"imull %2 ; " /* do the multiply */
"movl %%edx , %%eax ; "
: "=&a" (result) /* the result has to go in eax */
: "mr" (x), /* x and y can be regs or mem */
"mr" (y)
: "%edx", "%cc" /* clobbers edx and flags */
);
return result;
}
#endif /* _MSC_VER */
对于超过几行的汇编代码,维护两种风格可能非常耗时,并且显然也容易出错并阻碍开发。对于大段汇编代码,代码被提取到一个单独的汇编文件中(即,非内联),并使用免费的 Netwide Assembler (NASM) 在 GCC 和 VC 平台上编译源代码。在 VCMAME 中,为每个汇编文件使用自定义构建步骤。
性能
在性能方面,目前 VC 和 GCC 编译器之间几乎没有区别,至少在 Athlon 硬件上是如此。我使用 VC7.1 编译器(标准的 VCMAME release 模式优化设置)和 GCC 3.2.2 编译器(标准 release 构建,如 usual MAME makefiles 和 MAME 'Pentium Pro' 构建中所设置)进行了一些测试。
使用了以下命令行开关,以无节流的方式运行模拟器一段时间(1500 帧),并计时执行这些帧所需的时间。
Mame <game> -dd -noafs -nothrottle -ftr 1500 -r 800x600x32 -window
-refresh 60 -nowaitvsync -norc -nosleep -effect none -skip_disclaimer
-skip_gameinfo -noart -nobezel -nooverlay
每个构建版本都运行了三次,并对结果进行了平均。使用的硬件是 1GHz Athlon,512MB PC133 RAM,GeForce 3 视频。
游戏 | VCMAME | MAME | MAMEPP |
Double Dragon | 93 fps | 91 fps | 92 fps |
Twinbee Yahhoo | 84 fps | 82 fps | 84 fps |
Landmaker | 60 fps | 57 fps | 61 fps |
1943 | 120 fps | 118 fps | 119 fps |
结论
希望本文能稍微解释一下 MAME 的内部工作原理,它不是一本详尽的指南,只是一个入门。如果您想尝试使用 Visual C 构建 MAME,请先从 http://www.mame.net/ 下载源代码包,截至撰稿时,当前版本是 0.72。然后,从 http://www.vcmame.net/ 下载 VCMAME
包,并将其应用到基础源代码包上。如果您遵循 VCMAME
提供的说明,您将很快完成构建。