近期有些百无聊赖。因为工作,先前的个人项目全都不得不暂时搁置。为了找点乐子,我决定做一些与全栈开发不同的事情 (主要是最近玩宝可梦玩的有点多)

这期的文章参考了 Imre Nazar 在 2010 年写的一系列关于用 JavaScript 实现 GameBoy 模拟器的教程。虽然这个教程有些年头了,但还是提供了一个很好的起点和思路。自然,这期文章不会直接去 Ctrl C + V 他的实现,而是会使用更现代的 ES6 语法。

前言

要模拟一台 GameBoy,我们至少需要模拟以下核心组件:

  • CPU(Z80 兼容处理器),也就是“大脑”,负责执行游戏代码
  • 内存管理单元(MMU),用于处理内存的读写,包括 ROM、RAM 和各种硬件寄存器
  • 图形处理单元(PPU),负责渲染游戏画面
  • 输入设备,处理用户按键操作
  • 定时器,提供精确的时间控制
  • 声音处理单元,生成游戏音效和音乐

1. 模拟 Z80 CPU

GameBoy 的 CPU 是一个修改过的 Zilog Z80 处理器。要模拟它,就得理解它的工作方式。

核心概念很简单:取指 → 解码 → 执行循环。

  1. 取指:从内存中获取下一条指令
  2. 解码:解析指令的含义
  3. 执行:执行指令指定的操作

这个循环在 GameBoy 上电后就开始运行,直到关机。为了跟踪程序执行到哪里,CPU 会使用一个特殊的寄存器——程序计数器。每当一条指令被取出后,程序计数器就会根据指令的长度向前移动,指向下一条要执行的指令。

Z80 CPU 是一个 8 位芯片,意味着它一次可以处理一个字节的数据。它也能访问多达 6,5536 字节的内存空间,程序代码和普通数据都被存储在同一个内存地址空间中,而一条指令的长度可以在 1 到 3 个字节之间。

除了程序计数器,Z80 CPU 还有一组内存寄存器,用于存储数据和执行计算:

  • 8 位通用寄存器(A、B、C、D、E、H、L),每个可以存储一个字节(0 到 255)的值。大多数 Z80 指令都围绕着操作这些寄存器,例如将内存中的值加载到寄存器中,或者对寄存器中的值进行算术运算
  • 标志寄存器(F)是一个特殊的 8 位寄存器,其中每个位都代表一个“标志”,用于存储上一次运算的结果状态
  • 栈指针(SP)是一个 16 位寄存器,用于指向内存中的“栈顶”位置
    • 栈是一种后进先出的数据结构

CPU 是什么?(用大白话说)

不要被“中央处理器”这个名字吓到。

把 CPU 想象成一个超快的计算器工人:

  • 他有一张小纸条(寄存器),记录当前的数字
  • 他有一本操作手册(指令集),告诉他怎么计算
  • 他一次只能做一件事,但做得很快

假设有一个指令是“把 A 和 B 加起来”,那么对于这个工人而言就是:

  1. 看看 A 纸条(3)
  2. 看看 B 纸条(5)
  3. 算出结果(8)
  4. 写到 A 纸条上

基本上就是一个只会加减乘除但超级勤快的员工

基于以上理解,我们的 Z80 CPU 模拟器需要包含以下核心组件:

  • 内部状态,需要保存所有寄存器的当前值、执行上一条指令所花费的时间,以及 CPU 总共运行了多长时间
  • 指令模拟函数
  • 指令映射表
  • 内存接口

1.1. CPU 骨架

下面是我们 GameBoyCPU 类的初步骨架,包含了 CPU 的时钟系统和所有重要的寄存器:

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* GameBoy Z80 CPU 类
*/
class GameBoyCPU {
constructor() {
// 时钟系统,跟踪 CPU 执行时间
this.clock = {
m: 0, // 机器周期计数器(主时钟)
t: 0, // 时钟周期计数器,用于精确计时
};

// CPU 寄存器组
this.registers = {
// === 8位通用寄存器 ===
a: 0, // 累加器,主要用于算术运算

// BC寄存器对(可作为16位使用)
b: 0,
c: 0,

// DE寄存器对(同上)
d: 0,
e: 0,

// HL寄存器对(同上;常用作内存指针)
h: 0, // 高位
l: 0, // 低位

f: 0, // 标志寄存器,存储运算结果的状态标志

// === 16位专用寄存器 ===
pc: 0, // 程序计数器,指向下一条要执行的指令
sp: 0, // 栈指针,指向栈顶位置

// === 指令执行时间记录 ===
m: 0,
t: 0,
};
}
}

CPU 的时钟系统是用来精确跟踪模拟时间的,m 代表机器周期,t 代表时钟周期,两者之间存在固定关系。

1.2. 标志寄存器与基本指令

标志寄存器是 CPU 运算中一个非常关键的部分,它会根据上一条指令的执行结果自动设置某些位。在 GameBoy 的 Z80 CPU 中,有四个重要的标志位:

  • 零标志(0x80):如果上一次运算的结果为 0,则设置此位
  • 减法标志(0x40):如果上一次运算是减法操作,则设置此位
  • 半进位标志(0x20):如果上一次运算在字节的低 4 位发生了溢出(即结果的第 3 位向第 4 位进位),则设置此位
  • 进位标志(0x10):如果上一次加法运算结果超过 255(8 位最大值),或者减法运算结果低于 0(发生借位),则设置此位

看不懂的话……

想象你刚做完一道数学题,你的大脑会自动记住一些“状态”:

  • “咦,答案是 0?” → 零标志
  • “我刚才是在做减法吗?” → 减法标志
  • “有没有进位?” → 进位标志

GameBoy 的 CPU 也是如此。每次计算完,它都会在标志寄存器里记下这些“感想”。

为什么需要这些标志?因为后面的指令可能会问:“上次计算结果是 0 吗?如果是的话,跳转到别的地方。”

为了更好地管理这些标志位,我们首先定义一个常量对象 CPU_FLAGS

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
/**
* 标志位常量定义
*/
const CPU_FLAGS = {
ZERO: 0x80,
OPERATION: 0x40,
HALF_CARRY: 0x20,
CARRY: 0x10,
};

接着,我们为 GameBoyCPU 类添加 resetsetFlaggetFlag 方法:

  • reset 方法用于将 CPU 的所有寄存器和时钟状态复位到初始值,这对于模拟器启动或重新加载游戏非常有用
  • setFlaggetFlag 则分别用于设置和检查标志位,方便我们根据运算结果来操作标志寄存器 f
cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ... 之前的 constructor ...

/**
* CPU 重置,将所有寄存器和时钟复位为初始状态
*/
reset() {
// 重置所有8位寄存器
this.registers.a = 0;
this.registers.b = 0;
this.registers.c = 0;
this.registers.d = 0;
this.registers.e = 0;
this.registers.h = 0;
this.registers.l = 0;
this.registers.f = 0;

// 重置16位寄存器
this.registers.pc = 0; // 程序从地址0开始执行
this.registers.sp = 0;

// 重置时钟
this.clock.m = 0;
this.clock.t = 0;

console.log('主人~ CPU 已重置到初始状态喵!');
}

/**
* 设置标志位
* @param {number} flag 要设置的标志位
* @param {boolean} condition 是否设置该标志位
*/
setFlag(flag, condition) {
if(condition) {
this.registers.f |= flag; // 设置位:通过按位或操作将指定标志位设置为 1
} else {
// 否则就清除位:通过按位与操作与指定标志位的补码,将其设置为 0
this.registers.f &= ~flag;
}
}

/**
* 检查标志位是否设置
* @param {number} flag 要检查的标志位
*/
getFlag(flag) {
return (this.registers.f & flag) !== 0;
}

为了演示 CPU 如何执行指令并影响寄存器和标志位,我们来模拟几个基本的 Z80 指令:

  • ADD A, E(加法指令):将寄存器 e 的值加到寄存器 a 中,结果存回 a。这个函数需要更新 a 寄存器的值,并根据结果设置零标志和进位标志。
    注意,我们将结果限制在 8 位范围内(&= 255),并更新指令执行所花费的机器周期 m 和时钟周期 t

    cpu.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /**
    * 将 E 寄存器的值加到 A 寄存器
    * 算术指令用:ADD A, E
    */
    addRegisterE() {
    this.registers.a += this.registers.e;

    // 清除所有标志位并准备设置新的
    this.registers.f = 0;

    // 检查零标志和进位标志
    this.setFlag(CPU_FLAGS.ZERO, !(this.registers.a & 255)); // 结果为0则设置零标志
    this.setFlag(CPU_FLAGS.CARRY, this.registers.a > 255); // 结果溢出255则设置进位标志

    // 将结果限制为 8 位
    this.registers.a &= 255;

    // 设置指令执行时间(1 机器周期,4 时钟周期)
    this.registers.m = 1;
    this.registers.t = 4;
    }
  • CP A, B(比较指令):将寄存器 a 的值与寄存器 b 的值进行比较。这个指令实际上是执行 a - b 的操作,但不保存结果,只根据结果设置标志位。我们需要设置减法标志,并根据比较结果设置零标志(如果 a == b)和进位标志(如果 a < b)。

    cpu.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /**
    * 比较 B 寄存器和 A 寄存器
    * 用于条件判断和循环控制:CP A, B
    */
    compareRegisterB() {
    // 创建 A 的副本并模拟 A - B
    let result = this.registers.a;
    result -= this.registers.b;

    // 设置减法标志
    this.registers.f = CPU_FLAGS.OPERATION;

    // 检查 A 是否等于 B(结果为 0)以及 A 是否小于 B(结果为负,产生借位)
    this.setFlag(CPU_FLAGS.ZERO, !(result & 255)); // 结果为0则设置零标志
    this.setFlag(CPU_FLAGS.CARRY, result < 0); // 结果为负(下溢)则设置进位标志

    // 设置指令执行时间(1 机器周期,4 时钟周期)
    this.registers.m = 1;
    this.registers.t = 4;
    }
  • NOP(无操作指令):这个指令不做任何事情,仅仅消耗 CPU 周期,用来延时或指令对齐。

    cpu.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    /**
    * 无操作指令,不执行任何操作只是浪费时间
    * 延时或指令对齐用:NOP
    */
    noOperation() {
    this.registers.m = 1;
    this.registers.t = 4;
    }
  • 最后弄个调试工具,要打印出当前所有寄存器的十六进制值、标志位的状态以及时钟技术。

    cpu.jsjavascript
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * 获取当前 CPU 状态的字符串表示,用于调试
    */
    getStatusString() {
    const r = this.registers;
    return `CPU状态:
    寄存器: A=${r.a.toString(16).padStart(2,'0')} B=${r.b.toString(16).padStart(2,'0')} C=${r.c.toString(16).padStart(2,'0')}
    D=${r.d.toString(16).padStart(2,'0')} E=${r.e.toString(16).padStart(2,'0')} H=${r.h.toString(16).padStart(2,'0')} L=${r.l.toString(16).padStart(2,'0')}
    标志位: F=${r.f.toString(16).padStart(2,'0')} [Z:${this.getFlag(CPU_FLAGS.ZERO)?1:0} N:${this.getFlag(CPU_FLAGS.OPERATION)?1:0} H:${this.getFlag(CPU_FLAGS.HALF_CARRY)?1:0} C:${this.getFlag(CPU_FLAGS.CARRY)?1:0}]
    PC=${r.pc.toString(16).padStart(4,'0')} SP=${r.sp.toString(16).padStart(4,'0')}
    时钟: M=${this.clock.m} T=${this.clock.t}`;
    }

1.3. 完整指令集与执行循环

在前面我们实现了几个示例指令,但真正的 GameBoy CPU 需要支持完整的 Z80 指令集。现代模拟器的核心是建立一个高效的“取值 → 解码 → 执行”循环,这个循环每秒要执行数百万次。

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 指令时序常量(T 周期)
*/
const INSTRUCTION_TIMINGS = {
// 基础指令时序
NOP: 4, // 0x00: NOP
LD_BC_nn: 12, // 0x01: LD BC,nn
LD_MEM_BC_A: 8, // 0x02: LD (BC),A
INC_BC: 8, // 0x03: INC BC
INC_B: 4, // 0x04: INC B
DEC_B: 4, // 0x05: DEC B
LD_B_n: 8, // 0x06: LD B,n

// 跳转指令
JR_s8: 12, // 0x18: JR s8
JR_NZ_s8: 8, // 0x20: JR NZ,s8 (8 if not taken, 12 if taken)
JR_Z_s8: 8, // 0x28: JR Z,s8

// 算术指令
ADD_A_r: 4, // 0x80-0x87: ADD A,r
SUB_r: 4, // 0x90-0x97: SUB r
AND_r: 4, // 0xA0-0xA7: AND r
CP_r: 4, // 0xB8-0xBF: CP r

// 其他
HALT: 4, // 0x76: HALT
RET: 16, // 0xC9: RET
};

指令映射是现代模拟器的核心技术。我们创建了一个 256 元素的数组,每个元素对应一个操作码(opcode),直接指向对应的函数。这避免了复杂的 switch-case 语句,大幅提升执行效率:

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 构建指令映射表
*/
buildInstructionMap() {
const map = new Array(256);

// 基础指令集
map[0x00] = this.nop.bind(this); // NOP
map[0x01] = this.ld_bc_nn.bind(this); // LD BC,nn
map[0x02] = this.ld_mem_bc_a.bind(this); // LD (BC),A
map[0x03] = this.inc_bc.bind(this); // INC BC
map[0x04] = this.inc_b.bind(this); // INC B
map[0x05] = this.dec_b.bind(this); // DEC B
map[0x06] = this.ld_b_n.bind(this); // LD B,n

// 跳转指令
map[0x18] = this.jr_s8.bind(this); // JR s8
map[0x20] = this.jr_nz_s8.bind(this); // JR NZ,s8
map[0x28] = this.jr_z_s8.bind(this); // JR Z,s8

// 算术指令 - ADD A,r(批量注册)
for (let i = 0x80; i <= 0x87; i++) {
map[i] = this.add_a_r.bind(this, i & 0x07);
}

// 比较指令 - CP r(批量注册)
for (let i = 0xB8; i <= 0xBF; i++) {
map[i] = this.cp_r.bind(this, i & 0x07);
}

// 填充未实现的指令
for (let i = 0; i < 256; i++) {
if (!map[i]) {
map[i] = this.unimplemented.bind(this, i);
}
}

return map;
}

CPU 执行的核心是 step() 方法。每次调用它会执行一条指令,这个方法每秒会被调用数百万次:

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 执行单条指令
*/
step() {
if (this.halted || this.stopped) {
// CPU 处于暂停状态,只更新时钟
this.registers.t = 4;
this.registers.m = 1;
this.updateClocks();
return;
}

if (!this.mmu) {
throw new Error('CPU 未连接到 MMU');
}

try {
// 取指令:从 PC 指向的内存地址读取操作码
const opcode = this.mmu.readByte(this.registers.pc);
this.registers.pc = (this.registers.pc + 1) & 0xFFFF;

// 记录指令(用于调试)
this.stats.lastInstruction = opcode;

// 解码并执行:直接通过映射表调用对应函数
this.instructionMap[opcode]();

// 更新统计
this.stats.instructionsExecuted++;
this.stats.lastCycles = this.registers.t;
this.stats.totalCycles += this.registers.t;

// 更新时钟
this.updateClocks();

} catch (error) {
console.error(`❌ CPU 执行错误 PC=0x${this.registers.pc.toString(16).padStart(4, '0')}:`, error);
throw error;
}
}

为了简化指令实现,我们提供了一组辅助方法来处理常见的寄存器操作:

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 获取寄存器值(按编号)
*/
getRegisterValue(regNum) {
switch (regNum) {
case 0: return this.registers.b;
case 1: return this.registers.c;
case 2: return this.registers.d;
case 3: return this.registers.e;
case 4: return this.registers.h;
case 5: return this.registers.l;
case 6: return this.mmu.readByte(this.getHL()); // (HL)
case 7: return this.registers.a;
default: return 0;
}
}

/**
* 获取 16 位寄存器组合
*/
getBC() { return (this.registers.b << 8) | this.registers.c; }
getDE() { return (this.registers.d << 8) | this.registers.e; }
getHL() { return (this.registers.h << 8) | this.registers.l; }

setBC(value) {
this.registers.b = (value >> 8) & 0xFF;
this.registers.c = value & 0xFF;
}

1.4. 导出

在文件的最下方、GameBoyCPU 类的外部添加导出代码:

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
if (typeof module !== 'undefined' && module.exports) {
// Node.js 环境导出
module.exports = { GameBoyCPU, CPU_FLAGS, INSTRUCTION_TIMINGS };
} else if (typeof window !== 'undefined') {
// 浏览器环境导出到全局对象
window.GameBoyCPU = GameBoyCPU;
window.CPU_FLAGS = CPU_FLAGS;
window.INSTRUCTION_TIMINGS = INSTRUCTION_TIMINGS;
}

2. 模拟内存管理单元

我们的 GameBoy CPU 骨架虽然能够执行指令并管理内存寄存器,但是一个没有内存的 CPU 就跟一个没有书的图书馆差不多,光有管理员也没啥用。CPU 必需能够与外部内存交互,才能读取程序代码、存取数据,并与各种硬件组件通信。

这就是内存管理单元(MMU)的作用。MMU 负责管理 GameBoy 的整个 64KB 的地址空间,并将 CPU 的内存访问请求路由到正确的物理内存区域。

为什么需要 MMU?(大白话+1)

想象你在管理一个大仓库,有多大呢?GameBoy 的 64KB 内存相当于有 65536 个格子,你的大仓库也有这 65536 个格子:

  • 有些格子放游戏程序(ROM)
  • 有些格子放临时数据(RAM)
  • 有些格子放图片数据(VRAM)
  • 有些格子是控制按钮(I/O 寄存器)

CPU 依然是那个工人。假设他想要 0x8000 地址的数据。

如果没有 MMU 的话,CPU 就得自己跑到 0x8000 格子、搞清楚这个格子到底存的是什么、自行处理各种复杂情况。

有了 MMU 的话,CPU 只需要说他要 0x8000 的数据,MMU 就会:

  1. 0x8000 是图片数据区域。”
  2. “这个区域的数据在 VRAM 里。”
  3. “给你!”

这样看,MMU 就像是一个很靠谱的仓库管理员。

2.1. GameBoy 内存映射

与现代计算机复杂的内存管理不同,GameBoy 的内存映射相对直观,但仍然包含了多个不同功能的区域。理解这个内存映射对于正确模拟 GameBoy 至关重要。

GameBoy 的 64KB 地址空间被划分为以下主要区域:

  • 0x0000 - 0x3FFF:卡带 ROM 银行 0
    这是游戏卡带程序的第一个 16KB 区域,始终可访问。
    • 0x0000 - 0x00FF:BIOS:GameBoy 启动时,CPU 从 0x0000 地址开始执行 BIOS 代码。一旦 BIOS 运行完毕,这片区域就会被卡带 ROM 覆盖,不再可访问。
    • 0x0100 - 0x014F:卡带头部:这部分包含游戏的名称、制造商、ROM/RAM 大小等关键信息。
  • 0x4000 - 0x7FFF:卡带 ROM 其他银行
    对于大于 32KB 的游戏,卡带会包含多个 16KB 的 ROM 银行。MMU 需要通过内存银行控制器(MBC)来切换这些银行,以便 CPU 能够访问整个游戏程序。对于 32KB 或更小的游戏,这个区域也直接是 ROM 的一部分,无需银行切换。
  • 0x8000 - 0x9FFF:视频 RAM
    存储用于渲染游戏背景和精灵图形数据。这部分内存对 CPU 可读写,但主要由图形处理单元(GPU)使用。
  • 0xA000 - 0xBFFF:卡带外部 RAM
    部分游戏卡带会包含额外的可读写内存,用于保存游戏进度或临时数据。
  • 0xC000 - 0xDFFF:工作 RAM
    这是 GameBoy 内部的主工作内存,CPU 可以自由读写,用于存储程序变量、栈等。
  • 0xE000 - 0xFDFF:工作 RAM 镜像
    由于硬件接线的设计,工作 RAM 在内存映射中有一个完全相同的镜像区域。这意味着对 0xE000-0xFDFF 的读写实际上是对 0xC000-0xDFFF 的读写。
  • 0xFE00 - 0xFE9F:精灵属性表
    存储屏幕上所有精灵(例如角色、敌人)的位置、大小、图形数据索引等属性信息。
  • 0xFF00 - 0xFF7F:内存映射 I/O 寄存器
    这个区域包含了控制 GameBoy 各个硬件子系统(如图形、声音、定时器、输入)的寄存器。CPU 通过读写这些地址来控制硬件行为。
  • 0xFF80 - 0xFFFF:零页 RAM
    又称“高速 RAM”,这片区域是 GameBoy 最顶部的内存,CPU 访问它们的速度最快。虽然地址很高,但因其在汇编编程中的常用性,常被称为“零页”。

太复杂了看不懂、一图胜千言版本:

GameBoy 的 64KB 内存就像一栋 8 层楼的公寓:

🏢 GameBoy 内存公寓(64KB)
├── 8 楼(0xFF00-0xFFFF)控制中心(按钮、音量调节等)
├── 7 楼(0xFE00-0xFEFF)精灵属性(角色信息)
├── 6 楼(0xE000-0xFDFF)工作区镜像(楼下的复印件)
├── 5 楼(0xC000-0xDFFF)工作区(临时文件柜)
├── 4 楼(0xA000-0xBFFF)卡带存档(游戏进度)
├── 3 楼(0x8000-0x9FFF)图片仓库(所有图像数据)
├── 2 楼(0x4000-0x7FFF)游戏程序(第 2 部分)
└── 1 楼(0x0000-0x3FFF)游戏程序(第 1 部分)
└── 地下室(0x0000-0x00FF)开机程序(BIOS)

每次 CPU 说他想要 0x8000 的数据,MMU 就知道:“哦,你要 3 楼图片仓库的东西。”

为了在代码中清晰地表示这些区域,我们定义一个 MEMORY_REGIONS 常量对象:

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 内存区域常量定义
*/
const MEMORY_REGIONS = {
// ROM 区域
ROM_BANK_0_START: 0x0000,
ROM_BANK_0_END: 0x3FFF,
ROM_BANK_1_START: 0x4000,
ROM_BANK_1_END: 0x7FFF,

// BIOS 区域(在 ROM 银行 0 内)
BIOS_START: 0x0000,
BIOS_END: 0x00FF,
BIOS_EXIT_POINT: 0x0100,

// 卡带头部(在 ROM 银行 0 内)
CARTRIDGE_HEADER_START: 0x0100,
CARTRIDGE_HEADER_END: 0x014F,

// 视频 RAM
VRAM_START: 0x8000,
VRAM_END: 0x9FFF,
VRAM_SIZE: 0x2000, // 8KB

// 外部 RAM
ERAM_START: 0xA000,
ERAM_END: 0xBFFF,
ERAM_SIZE: 0x2000, // 8KB

// 工作 RAM
WRAM_START: 0xC000,
WRAM_END: 0xDFFF,
WRAM_SIZE: 0x2000, // 8KB

// 工作 RAM 镜像
WRAM_SHADOW_START: 0xE000,
WRAM_SHADOW_END: 0xFDFF,

// 精灵属性内存
OAM_START: 0xFE00,
OAM_END: 0xFE9F,
OAM_SIZE: 0xA0, // 160 字节

// I/O 寄存器
IO_START: 0xFF00,
IO_END: 0xFF7F,

// 零页 RAM
ZRAM_START: 0xFF80,
ZRAM_END: 0xFFFF,
ZRAM_SIZE: 0x80 // 128 字节
};

2.2. MMU 类结构与初始化

我们的 GameBoyMMU 类会负责创建和管理这些内存区域的实际存储(用 Unit8Array),并提供读写内存的接口。

在构造函数里,首先要调用 initializeMemoryRegions 来分配各个内存区域的存储空间,然后调用 reset 方法将它们清空并设置初始状态。

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
/**
* GameBoy 内存管理单元类
*/
class GameBoyMMU {
constructor() {
this.initializeMemoryRegions();
this.reset();
}

/**
* 初始化所有内存区域
*/
initializeMemoryRegions() {
// BIOS 数据(256 字节),GameBoy 启动代码
this.bios = new Uint8Array(256);

// 卡带 ROM 数据(最大 32KB 基础 ROM)
this.rom = new Uint8Array(0x8000); // 32KB 初始空间

// 视频 RAM(8KB),存储背景和精灵图形数据
this.vram = new Uint8Array(MEMORY_REGIONS.VRAM_SIZE);

// 外部 RAM(8KB),卡带上的额外可写内存
this.eram = new Uint8Array(MEMORY_REGIONS.ERAM_SIZE);

// 工作 RAM(8KB),GameBoy 内部 RAM
this.wram = new Uint8Array(MEMORY_REGIONS.WRAM_SIZE);

// 零页 RAM(128 字节),高速访问内存
this.zram = new Uint8Array(MEMORY_REGIONS.ZRAM_SIZE);

// 精灵属性内存(160 字节),存储精灵位置和属性
this.oam = new Uint8Array(MEMORY_REGIONS.OAM_SIZE);

// I/O 寄存器映射(128 字节),硬件控制寄存器
this.ioRegisters = new Uint8Array(0x80);
}

/**
* 重置 MMU 到初始状态
*/
reset() {
// BIOS 映射标志,控制是否显示 BIOS 区域
this.biosEnabled = true;

// 清空所有可写内存区域
this.vram.fill(0);
this.eram.fill(0);
this.wram.fill(0);
this.zram.fill(0);
this.oam.fill(0);
this.ioRegisters.fill(0);

console.log('MMU 已重置到初始状态');
}
}

2.3. 内存读取

MMU 最核心的功能就是根据 CPU 请求的地址,将其路由到正确的内存区域并返回数据。readByte(address) 方法实现了这一逻辑:它根据 16 位地址的不同范围,返回对应 Uint8Array 中的字节。

这里需要特别注意 BIOS 区域的逻辑:当 CPU 程序计数器 PC 达到 0x0100 时,表明 BIOS 已经执行完毕,此时我们会禁用 BIOS 映射(this.biosEnabled = false;),让该地址范围(0x0000-0x00FF)切换到显示卡带 ROM。

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
// ... 之前写的方法 ...

/**
* 从指定地址读取 8 位字节
* 根据地址范围路由到相应的内存区域
* @param {number} address 16 位内存地址(0x0000-0xFFFF)
* @returns {number} 8 位数据值(0x00-0xFF)
*/
readByte(address) {
// 确保地址在 16 位范围内
address &= 0xFFFF;

// 根据地址高 4 位进行初步分类,提升性能
switch (address & 0xF000) {
// 0x0000-0x0FFF: BIOS/ROM 银行 0 区域
case 0x0000: {
// 检查是否在 BIOS 区域且 BIOS 已启用
if (this.biosEnabled && address < MEMORY_REGIONS.BIOS_EXIT_POINT) {
return this.bios[address];
}

// BIOS 退出检查:当 PC 到达 0x0100 时禁用 BIOS
// TODO: 这里依赖于全局的 cpu 实例,后续可以考虑通过依赖注入优化
if (address === MEMORY_REGIONS.BIOS_EXIT_POINT && window.cpu?.registers?.pc === MEMORY_REGIONS.BIOS_EXIT_POINT) {
this.biosEnabled = false;
console.log('BIOS已退出,切换到卡带ROM');
}

return this.rom[address]; // 否则读取 ROM
}

// 0x1000-0x3FFF: ROM 银行 0 的其余部分
case 0x1000:
case 0x2000:
case 0x3000:
return this.rom[address];

// 0x4000-0x7FFF: ROM 银行 1(可切换) - 基础模拟中直接从 rom 读取
case 0x4000:
case 0x5000:
case 0x6000:
case 0x7000:
return this.rom[address];

// 0x8000-0x9FFF: 视频 RAM
case 0x8000:
case 0x9000:
return this.vram[address & 0x1FFF]; // 限制在 8KB(0x2000)范围内

// 0xA000-0xBFFF: 外部 RAM
case 0xA000:
case 0xB000:
return this.eram[address & 0x1FFF]; // 限制在 8KB 范围内

// 0xC000-0xDFFF: 工作 RAM
case 0xC000:
case 0xD000:
return this.wram[address & 0x1FFF]; // 限制在 8KB 范围内

// 0xE000-0xEFFF: 工作 RAM 镜像
case 0xE000:
return this.wram[address & 0x1FFF]; // 映射到工作 RAM

// 0xF000-0xFFFF: 复杂区域 - 包含 RAM 镜像、OAM、I/O、零页 RAM
case 0xF000:
return this.readHighMemoryRegion(address); // 调用辅助函数处理高地址区域

default:
console.warn(`未处理的内存读取地址: 0x${address.toString(16).padStart(4, '0')}`);
return 0xFF; // 返回未连接总线的典型值(全1)
}
}

/**
* 处理高内存区域的读取(0xF000-0xFFFF)
* 这个区域包含多个不同的内存映射,需要进一步细分
* @param {number} address 内存地址
* @returns {number} 读取的数据
*/
readHighMemoryRegion(address) {
// 根据地址的中间 4 位进一步分类
switch (address & 0x0F00) {
// 0xF000-0xFDFF: 工作 RAM 镜像的剩余部分
case 0x000: case 0x100: case 0x200: case 0x300:
case 0x400: case 0x500: case 0x600: case 0x700:
case 0x800: case 0x900: case 0xA00: case 0xB00:
case 0xC00: case 0xD00:
return this.wram[address & 0x1FFF];

// 0xFE00-0xFEFF: 精灵属性内存区域(OAM)
case 0xE00:
if (address < 0xFEA0) {
// 有效的 OAM 区域(0xFE00-0xFE9F)
return this.oam[address & 0xFF]; // 限制在 160 字节(0xA0)范围内
} else {
// 0xFEA0-0xFEFF: 未使用区域,读取通常返回 0 或 0xFF,这里统一返回 0
return 0;
}

// 0xFF00-0xFFFF: I/O 寄存器和零页 RAM
case 0xF00:
if (address >= MEMORY_REGIONS.ZRAM_START) {
// 0xFF80-0xFFFF: 零页 RAM
return this.zram[address & 0x7F]; // 限制在 128 字节(0x80)范围内
} else {
// 0xFF00-0xFF7F: I/O 寄存器
return this.readIORegister(address); // 调用辅助函数处理 I/O 寄存器读取
}

default:
return 0xFF; // 未知区域返回 0xFF
}
}

/**
* 读取 I/O 寄存器
* 这些寄存器控制 GameBoy 的各个硬件子系统。
* TODO: 目前只是返回其在内部数组中的值,具体硬件逻辑将在后续章节实现。
* @param {number} address I/O 寄存器地址(0xFF00-0xFF7F)
* @returns {number} 寄存器值
*/
readIORegister(address) {
const registerIndex = address - MEMORY_REGIONS.IO_START;
// 返回 I/O 寄存器数组中的值,如果超出范围则返回 0(或 0xFF)
return this.ioRegisters[registerIndex] || 0;
}

/**
* 从指定地址读取 16 位字(小端序 - Little Endian)
* GameBoy CPU 使用小端序存储多字节数据,即低位字节在前。
* @param {number} address 起始地址
* @returns {number} 16位值
*/
readWord(address) {
const lowByte = this.readByte(address);
const highByte = this.readByte(address + 1);
return lowByte | (highByte << 8); // 将高位字节左移 8 位后与低位字节合并
}

2.4. 内存写入

内存写入的逻辑与读取类似,writeByte(address, value) 方法根据地址将数据写入对应的内存区域。需要注意的是,ROM 区域通常是只读的,任何写入操作都会被忽略(除了用于内存银行切换的特殊地址,这将在后面讨论)。

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// ... 之前写的方法 ...

/**
* 向指定地址写入 8 位字节
* 根据地址范围路由到相应的内存区域
* @param {number} address 16 位内存地址
* @param {number} value 8 位数据值
*/
writeByte(address, value) {
// 确保地址和值在有效范围内
address &= 0xFFFF;
value &= 0xFF;

// 根据地址高 4 位进行分类
switch (address & 0xF000) {
// 0x0000-0x7FFF: ROM 区域 - 通常只读,但可能有银行切换逻辑
case 0x0000: case 0x1000: case 0x2000: case 0x3000:
case 0x4000: case 0x5000: case 0x6000: case 0x7000:
this.handleROMWrite(address, value); // 专门处理 ROM 区域的写入
break;

// 0x8000-0x9FFF: 视频 RAM - 可写
case 0x8000:
case 0x9000:
this.vram[address & 0x1FFF] = value;
break;

// 0xA000-0xBFFF: 外部 RAM - 可写
case 0xA000:
case 0xB000:
this.eram[address & 0x1FFF] = value;
break;

// 0xC000-0xDFFF: 工作 RAM - 可写
case 0xC000:
case 0xD000:
this.wram[address & 0x1FFF] = value;
break;

// 0xE000-0xEFFF: 工作 RAM 镜像 - 写入映射到工作 RAM
case 0xE000:
this.wram[address & 0x1FFF] = value;
break;

// 0xF000-0xFFFF: 高内存区域
case 0xF000:
this.writeHighMemoryRegion(address, value); // 调用辅助函数处理高地址区域写入
break;

default:
console.warn(`未处理的内存写入地址: 0x${address.toString(16).padStart(4, '0')}`);
}
}

/**
* 处理 ROM 区域的写入
* ROM 通常是只读的。在基础模拟中,任何对 ROM 区域的写入都会被忽略。
* TODO: 在未来的内存银行章节中,这里将包含处理卡带内存银行控制器的逻辑。
* @param {number} address 地址
* @param {number} value 值
*/
handleROMWrite(address, value) {
console.log(`ROM写入被忽略: 地址=0x${address.toString(16).padStart(4, '0')}, 值=0x${value.toString(16).padStart(2, '0')}`);
}

/**
* 处理高内存区域的写入(0xF000-0xFFFF)
* @param {number} address 地址
* @param {number} value 值
*/
writeHighMemoryRegion(address, value) {
switch (address & 0x0F00) {
// 0xF000-0xFDFF: 工作 RAM 镜像 - 写入映射到工作 RAM
case 0x000: case 0x100: case 0x200: case 0x300:
case 0x400: case 0x500: case 0x600: case 0x700:
case 0x800: case 0x900: case 0xA00: case 0xB00:
case 0xC00: case 0xD00:
this.wram[address & 0x1FFF] = value;
break;

// 0xFE00-0xFEFF: 精灵属性内存(OAM)
case 0xE00:
if (address < 0xFEA0) {
this.oam[address & 0xFF] = value;
}
// 0xFEA0-0xFEFF 区域的写入通常被忽略
break;

// 0xFF00-0xFFFF: I/O 寄存器和零页 RAM
case 0xF00:
if (address >= MEMORY_REGIONS.ZRAM_START) {
// 零页 RAM
this.zram[address & 0x7F] = value;
} else {
// I/O 寄存器
this.writeIORegister(address, value);
}
break;
}
}

/**
* 写入 I/O 寄存器
* @param {number} address I/O 寄存器地址
* @param {number} value 要写入的值
*/
writeIORegister(address, value) {
const registerIndex = address - MEMORY_REGIONS.IO_START;
this.ioRegisters[registerIndex] = value;

// TODO: 在后续章节中,这里将处理特定 I/O 寄存器写入的副作用
// 例如:当写入显示控制寄存器时触发屏幕更新,或写入声音寄存器时播放声音
}

/**
* 向指定地址写入 16 位字(小端序)
* @param {number} address 起始地址
* @param {number} value 16 位值
*/
writeWord(address, value) {
this.writeByte(address, value & 0xFF); // 写入低位字节
this.writeByte(address + 1, (value >> 8) & 0xFF); // 写入高位字节
}

2.5. 加载 ROM 与 BIOS

为了让模拟器能够运行游戏,MMU 还需要加载 BIOS 和游戏 ROM 的功能:

  • loadBIOS(biosData): 加载 GameBoy 的 BIOS 文件
    • BIOS 是启动时运行的一小段程序,通常用于初始化硬件和显示任天堂的标志
  • loadROM(romData): 加载游戏卡带的 ROM 数据。这个方法会将 ROM 数据复制到 MMU 内部的 this.rom 数组中
  • loadROMFromURL(url): 直接从给定的 URL 下载 ROM 文件并加载

displayCartridgeInfo() 方法则会从 ROM 的卡带头部读取并打印出游戏的标题、类型、ROM 大小和 RAM 大小等信息,这对于验证 ROM 是否正确加载非常有用。

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// ... 之前写的方法 ...

/**
* 加载 BIOS 数据
* @param {Uint8Array|Array} biosData BIOS 数据数组(应为 256 字节)
*/
loadBIOS(biosData) {
if (biosData.length !== 256) {
throw new Error('BIOS 数据必须正好是 256 字节');
}

this.bios.set(biosData); // 将传入的 BIOS 数据复制到内部存储
this.biosEnabled = true; // 确保 BIOS 映射处于启用状态
console.log('BIOS 已加载');
}

/**
* 加载 ROM 文件
* @param {Uint8Array|ArrayBuffer} romData ROM 数据
*/
loadROM(romData) {
// 转换为 Uint8Array(如果传入的是 ArrayBuffer)
const rom = romData instanceof ArrayBuffer ? new Uint8Array(romData) : romData;

// 确保 ROM 不超过 MMU 分配的最大支持大小
const maxSize = this.rom.length;
if (rom.length > maxSize) {
console.warn(`ROM 大小 ${rom.length} 字节超过最大支持大小 ${maxSize} 字节,将被截断`);
}

// 复制 ROM 数据到内部存储,只复制有效部分
const copySize = Math.min(rom.length, maxSize);
this.rom.set(rom.slice(0, copySize));

console.log(`ROM 已加载,大小: ${copySize} 字节`);

// 显示卡带信息,验证 ROM 是否加载正确
this.displayCartridgeInfo();
}

/**
* 显示卡带头部信息
* 从 ROM 的特定地址读取游戏元数据
*/
displayCartridgeInfo() {
// 读取卡带标题(0x0134-0x0143)
let title = '';
for (let i = 0x0134; i <= 0x0143; i++) {
const char = this.rom[i];
if (char === 0) break; // 标题以空字符结束
title += String.fromCharCode(char);
}

// 读取卡带类型(0x0147)
const cartridgeType = this.rom[0x0147];

// 读取 ROM 大小(0x0148)
const romSizeCode = this.rom[0x0148];
// 根据 GameBoy 规范计算 ROM 实际大小,32KB * (2^romSizeCode)
const romSize = 32 * (1 << romSizeCode); // KB

// 读取 RAM 大小(0x0149)
const ramSizeCode = this.rom[0x0149];
// 根据 GameBoy 规范定义 RAM 大小映射
const ramSizes = [0, 2, 8, 32, 128, 64]; // KB
const ramSize = ramSizes[ramSizeCode] || 0; // 处理未知编码

console.log('=== 卡带信息 ===');
console.log(`标题: ${title}`);
console.log(`卡带类型: 0x${cartridgeType.toString(16).padStart(2, '0')}`);
console.log(`ROM 大小: ${romSize}KB`);
console.log(`RAM 大小: ${ramSize}KB`);
}

/**
* 异步加载 ROM 文件(从 URL)
* @param {string} url ROM 文件 URL
*/
async loadROMFromURL(url) {
try {
console.log(`正在加载ROM: ${url}`);
const response = await fetch(url); // 发起网络请求

if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}

const romData = await response.arrayBuffer(); // 获取二进制数据
this.loadROM(romData); // 调用 loadROM 进行处理
} catch (error) {
console.error('ROM加载失败:', error);
throw error; // 重新抛出错误以便外部捕获
}
}

/**
* 获取内存使用情况统计(用于调试)
* @returns {Object} 内存使用统计对象
*/
getMemoryStats() {
return {
biosEnabled: this.biosEnabled,
romLoaded: this.rom.some(byte => byte !== 0), // 检查 ROM 是否包含数据
memoryRegions: {
bios: { size: this.bios.length, used: this.bios.some(byte => byte !== 0) },
rom: { size: this.rom.length, used: this.rom.some(byte => byte !== 0) },
vram: { size: this.vram.length, used: this.vram.some(byte => byte !== 0) },
eram: { size: this.eram.length, used: this.eram.some(byte => byte !== 0) },
wram: { size: this.wram.length, used: this.wram.some(byte => byte !== 0) },
zram: { size: this.zram.length, used: this.zram.some(byte => byte !== 0) },
oam: { size: this.oam.length, used: this.oam.some(byte => byte !== 0) }
}
};
}

/**
* 获取内存转储(用于调试)
* 以十六进制和 ASCII 形式显示内存内容
* @param {number} startAddr 起始地址
* @param {number} length 要转储的长度
* @returns {string} 十六进制转储字符串
*/
getMemoryDump(startAddr, length = 256) {
let dump = `内存转储 (0x${startAddr.toString(16).padStart(4, '0')} - 0x${(startAddr + length - 1).toString(16).padStart(4, '0')}):\n`;

// 每行显示 16 个字节
for (let i = 0; i < length; i += 16) {
const addr = startAddr + i;
let line = `${addr.toString(16).padStart(4, '0')}: `; // 地址部分
let ascii = ''; // ASCII 可视化部分

for (let j = 0; j < 16 && (i + j) < length; j++) {
const byte = this.readByte(addr + j);
line += `${byte.toString(16).padStart(2, '0')} `; // 十六进制字节
// 将可打印字符转换为 ASCII,否则显示 '.'
ascii += (byte >= 32 && byte <= 126) ? String.fromCharCode(byte) : '.';
}

line = line.padEnd(50, ' ') + ascii; // 填充空格对齐
dump += line + '\n';
}

return dump;
}

2.6. MMU 与 CPU 的连接

现在我们有了 GameBoyCPUGameBoyMMU 两个类。CPU 需要一个方式来与 MMU 交互,才能真正实现“取指”和“读写内存”的功能。最简单的方式就是将 MMU 实例作为参数传递给 CPU,或者让 CPU 拥有一个 MMU 的引用。

不过也别忘了在 mmu.js 中导出 GameBoyMMU 类和常量:

mmu.jsjavascript
1
2
3
4
5
6
7
8
if (typeof module !== 'undefined' && module.exports) {
// Node.js 环境导出
module.exports = { GameBoyMMU, MEMORY_REGIONS };
} else if (typeof window !== 'undefined') {
// 浏览器环境导出到全局对象
window.GameBoyMMU = GameBoyMMU;
window.MEMORY_REGIONS = MEMORY_REGIONS;
}

然后在 GameBoyCPU 类中,我们可以添加一个对 MMU 的引用:

cpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameBoyCPU {
constructor(mmu) { // 接收 MMU 实例
this.mmu = mmu; // 存储 MMU 引用
// ... 其他寄存器和时钟初始化 ...
}

// 示例:实现一个 LD A, (HL) 指令
// 将 HL 寄存器指向的内存地址的值加载到 A 寄存器
loadAToHL() {
const address = (this.registers.h << 8) | this.registers.l; // 合并 H 和 L 得到 16 位地址
this.registers.a = this.mmu.readByte(address); // 通过 MMU 读取内存
this.registers.m = 2; // 假设这条指令需要 2 机器周期
this.registers.t = 8; // 假设这条指令需要 8 时钟周期
}
// ... 其他指令和方法 ...
}

这样,CPU 就可以通过 this.mmu.readByte()this.mmu.writeByte() 等方法来访问内存了。

3. GPU 时序:让 GameBoy 屏幕动起来

先前我们已经构建了 GameBoy 模拟器的 CPU 骨架和 MMU,让模拟器能够加载游戏 ROM 并开始执行指令。但是一个没有图像输出的模拟器是没有灵魂的!现在我们得引入 GameBoy 的主要输出设备——图形处理单元也就是我们经常说到的 GPU 了。

GameBoy 的官方内部名称是“点阵式游戏机”(Dot Matrix Game),这是因为它靠着一块 160x144 像素的单色 LCD 屏幕来显示内容。为了模拟这个屏幕,我们可以在 Web 页面中使用一个 HTML5 <canvas> 元素。这个 Canvas 将作为我们的“帧缓冲区”,其中每个像素的颜色都可以被直接操作。

3.1. 模拟屏幕与帧缓冲区

要将 GameBoy 的图形输出呈现在网页上,最直接的方法就是创建一个与 GameBoy 屏幕尺寸相同的 Canvas。我们可以通过 Canvas 的 2D 渲染上下文来操作它的像素数据。

GameBoy 的显示分辨率是 160 像素宽和 140 像素高。Canvas 的像素数据通常以 RGBA(红、绿、蓝、透明度)的 4 字节序列存储。这意味着每个像素需要 4 个字节来表示其颜色。我们可以通过 getImageDatacreateImageData 方法获取或创建这个帧缓冲区。

首先,在 HTML 文件中添加一个 Canvas 元素:

index.htmlhtml
1
<canvas id="gameboy-screen" width="160" height="144"></canvas>

GameBoyGPU 类中,我们将负责初始化这个 Canvas 并创建帧缓冲区。我们还设置了 imageSmoothingEnabled = falseimage-rendering: pixelated 样式,以确保图像在放大时保持像素艺术的清晰度,而不是变得模糊。

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
// ... 其他常量定义,之后写 ...

/**
* GameBoy GPU 类
* 负责管理显示时序、渲染管线和帧缓冲区
*/
class GameBoyGPU {
constructor() {
this.initializeTimingState();
this.initializeCanvas();
this.initializeFrameBuffer();

console.log('GameBoy GPU 已初始化');
}

/**
* 初始化 Canvas 画布
*/
initializeCanvas() {
// 尝试获取已存在的 canvas 元素
this.canvas = document.getElementById('gameboy-screen');

// 如果不存在,创建新的 canvas 并添加到页面
if (!this.canvas) {
this.canvas = this.createCanvas();
}

// 获取 2D 渲染上下文
this.context = this.canvas.getContext('2d');

if (!this.context) {
throw new Error('无法获取 Canvas 2D 渲染上下文');
}

// 禁用图像平滑,保持像素艺术风格
this.context.imageSmoothingEnabled = false;

console.log('Canvas 画布已初始化');
}

/**
* 创建新的 Canvas 元素
* @returns {HTMLCanvasElement} 创建的 canvas 元素
*/
createCanvas() {
const canvas = document.createElement('canvas');
canvas.id = 'gameboy-screen';
canvas.width = DISPLAY_CONFIG.WIDTH;
canvas.height = DISPLAY_CONFIG.HEIGHT;
canvas.style.border = '2px solid #333';
canvas.style.imageRendering = 'pixelated'; // 保持像素完美缩放
canvas.style.imageRendering = '-moz-crisp-edges'; // Firefox
canvas.style.imageRendering = 'crisp-edges'; // Chrome/Edge
canvas.style.backgroundColor = '#9BBC0F'; // GameBoy 绿色背景
// 为了方便在 HTML 中演示,这里直接添加到 body
document.body.appendChild(canvas);

console.log('已创建新的 Canvas 元素');
return canvas;
}

/**
* 初始化帧缓冲区
* 使用 ImageData 对象作为屏幕的内存表示
*/
initializeFrameBuffer() {
// 创建图像数据对象(帧缓冲区)
this.frameBuffer = this.context.createImageData(
DISPLAY_CONFIG.WIDTH,
DISPLAY_CONFIG.HEIGHT
);

// 初始化为白色背景
this.clearFrameBuffer();

console.log('帧缓冲区已初始化');
}

/**
* 清空帧缓冲区(设置为白色,或 GameBoy 默认背景色)
*/
clearFrameBuffer() {
const data = this.frameBuffer.data;

// 设置所有像素为白色(255, 255, 255, 255)
for (let i = 0; i < DISPLAY_CONFIG.BUFFER_SIZE; i += 4) {
data[i] = 255; // R
data[i + 1] = 255; // G
data[i + 2] = 255; // B
data[i + 3] = 255; // A (透明度)
}
}

/**
* 设置像素颜色(辅助函数)
* @param {number} x - X 坐标(0-159)
* @param {number} y - Y 坐标(0-143)
* @param {number} r - 红色分量(0-255)
* @param {number} g - 绿色分量(0-255)
* @param {number} b - 蓝色分量(0-255)
* @param {number} a - 透明度(0-255),默认 255
*/
setPixel(x, y, r, g, b, a = 255) {
if (x < 0 || x >= DISPLAY_CONFIG.WIDTH || y < 0 || y >= DISPLAY_CONFIG.HEIGHT) {
return; // 越界检查
}

const pixelIndex = (y * DISPLAY_CONFIG.WIDTH + x) * 4;
const data = this.frameBuffer.data;

data[pixelIndex] = r;
data[pixelIndex + 1] = g;
data[pixelIndex + 2] = b;
data[pixelIndex + 3] = a;
}
}

现在,我们有一个可以操作的帧缓冲区。通过修改 this.frameBuffer.data 数组中的 RGBA 值,我们可以改变屏幕上任何像素的颜色。修改完成后,调用 this.context.putImageData(this.frameBuffer, 0, 0) 就可以将更新后的帧缓冲区内容绘制到 Canvas 上。

3.2. 栅格图形与显示时序

GPU 工作原理:

我们可以看一下老式电视是如何显示画面的:

plaintext
1
2
3
4
5
电子枪从左到右扫描 →→→→→→→→→→→(一行画完)
然后跳到下一行 ↓
再从左到右扫描 →→→→→→→→→→→(下一行)
...重复 144次 ...
最后回到顶部 ↑↑↑↑↑↑↑↑↑(准备下一帧)

GameBoy 的 GPU 完全模仿了这个过程。

1989 年的硬件很慢,一次只能处理一行像素,这样做也能省电省内存。当年的程序员很聪明,用最少的资源做最多的事。

GameBoy 的显示硬件模拟了阴极射线管(CRT)的工作方式。在 CRT 显示器中,电子束逐行扫描屏幕,并在扫描完成后返回到屏幕顶部。这个扫描过程不是瞬间完成的,它包含了消隐期:

  • 水平消隐:电子束从一行的末尾移动到下一行的开头所需的时间
  • 垂直消隐:在一帧结束后,电子束从屏幕底部回到屏幕左上角所需的时间。垂直消隐期通常比水平消隐期长得多,因为它需要移动更远的距离

GameBoy 的显示器也遵循类似的模式,并将其工作周期细分为四个不同的 GPU 模式。这对于精确模拟非常重要,因为某些硬件操作只能在特定的 GPU 模式下进行。

周期 GPU 模式号 时间消耗(t 时钟周期) 描述
OAM 搜索 2 80 GPU 扫描 OAM 获取当前扫描线上的精灵信息。
像素传输 3 172 GPU 从 VRAM 读取图块和地图数据并渲染像素。
水平消隐 0 204 当前扫描线渲染完成后的等待期。
一条扫描线总时间 456 (80 + 172 + 204)
垂直消隐 1 4560(10 行) 所有可见扫描线渲染完成后的等待期。
一帧总时间 70224 (456 * 154 行)

为什么是这些数字呢?

GPU 像是一个画家,每天画一行像素。

  1. 准备阶段(80):看看要画什么角色(OAM 搜索)
  2. 绘画阶段(172):专心画这一行(像素传输)
  3. 休息阶段(204):喝口水,准备下一行(水平消隐)
  4. 长休息(4560):一张画完了,休息下(垂直消隐)

因此我们的游戏不能随时访问 GPU。

为了在模拟器中保持这些时序,我们需要一个 step 函数,它会在 CPU 每执行一条指令后被调用,并根据 CPU 消耗的时钟周期来推进 GPU 的内部时序状态。

我们定义 GPU 模式和时序常量:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* GPU 模式常量定义
* GameBoy GPU 有 4 种不同的工作模式,模拟 CRT 显示器的扫描过程
*/
const GPU_MODES = {
HBLANK: 0, // 水平消隐期(扫描线结束后)
VBLANK: 1, // 垂直消隐期(帧结束后)
OAM_SEARCH: 2, // OAM 搜索期(精灵属性扫描)
PIXEL_TRANSFER: 3 // 像素传输期(VRAM 读取和渲染)
};

/**
* GPU 时序常量(以 CPU 的 T 时钟周期为单位)
* CPU 频率:4194304 Hz
*/
const GPU_TIMINGS = {
// 扫描线时序
OAM_SEARCH_CYCLES: 80, // 模式 2:OAM 访问时间
PIXEL_TRANSFER_CYCLES: 172, // 模式 3:VRAM 访问时间
HBLANK_CYCLES: 204, // 模式 0:水平消隐时间

// 计算得出的时序
SCANLINE_CYCLES: 456, // 一条扫描线总时间(80 + 172 + 204)
VBLANK_LINE_CYCLES: 456, // 垂直消隐期每线时间
VBLANK_TOTAL_CYCLES: 4560, // 垂直消隐总时间(456 * 10)

// 帧时序
VISIBLE_LINES: 144, // 可见扫描线数量
VBLANK_LINES: 10, // 垂直消隐扫描线数量
TOTAL_LINES: 154, // 总扫描线数量(144 + 10)
FRAME_CYCLES: 70224 // 完整帧时间(456 * 154)
};

/**
* 显示常量
*/
const DISPLAY_CONFIG = {
WIDTH: 160, // 屏幕宽度
HEIGHT: 144, // 屏幕高度
BYTES_PER_PIXEL: 4, // RGBA 格式,每像素 4 字节
TOTAL_PIXELS: 160 * 144, // 总像素数
BUFFER_SIZE: 160 * 144 * 4 // 帧缓冲区大小
};

接下来,我们在 GameBoyGPU 类中初始化时序状态,并实现 step 方法:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
// ... 之前写的其他方法 ...

/**
* 初始化时序状态
*/
initializeTimingState() {
this.mode = GPU_MODES.OAM_SEARCH; // 初始 GPU 模式
this.modeClock = 0; // 当前模式的时钟计数器
this.currentLine = 0; // 当前扫描线号(0-153)
this.totalClock = 0; // GPU 总时钟计数(累积)
this.frameCount = 0; // 帧计数器
this.lastFrameTime = performance.now(); // 上次帧渲染时间(用于计算 FPS)
}

/**
* 重置 GPU 到初始状态
*/
reset() {
this.initializeTimingState();
this.clearFrameBuffer();
this.updateDisplay(); // 更新 Canvas 以显示清空后的画面

console.log('GPU 已重置');
}

/**
* GPU 时序步进函数
* 每次 CPU 执行指令后调用,根据 CPU 消耗的时钟周期推进 GPU 状态
* @param {number} cycles CPU 消耗的时钟周期数
*/
step(cycles) {
this.modeClock += cycles; // 累加当前模式下的时钟
this.totalClock += cycles; // 累加总时钟

// 根据当前模式处理时序逻辑
switch (this.mode) {
case GPU_MODES.OAM_SEARCH:
this.handleOAMSearchMode();
break;

case GPU_MODES.PIXEL_TRANSFER:
this.handlePixelTransferMode();
break;

case GPU_MODES.HBLANK:
this.handleHBlankMode();
break;

case GPU_MODES.VBLANK:
this.handleVBlankMode();
break;

default:
console.warn(`未知的 GPU 模式:${this.mode}`);
this.mode = GPU_MODES.OAM_SEARCH; // 恢复到默认模式
}
}

/**
* 处理 OAM 搜索模式(模式 2)
* 当模式时钟达到 OAM_SEARCH_CYCLES 时,切换到像素传输模式
*/
handleOAMSearchMode() {
if (this.modeClock >= GPU_TIMINGS.OAM_SEARCH_CYCLES) {
this.modeClock = 0;
this.mode = GPU_MODES.PIXEL_TRANSFER;

// TODO: 在这个阶段,会进行 OAM 搜索逻辑,在第 7 章(精灵)中实现
this.searchOAM();
}
}

/**
* 处理像素传输模式(模式 3)
* 当模式时钟达到 PIXEL_TRANSFER_CYCLES 时,切换到水平消隐模式,并渲染当前扫描线
*/
handlePixelTransferMode() {
if (this.modeClock >= GPU_TIMINGS.PIXEL_TRANSFER_CYCLES) {
this.modeClock = 0;
this.mode = GPU_MODES.HBLANK;

// 渲染当前扫描线
this.renderScanline();
}
}

/**
* 处理水平消隐模式(模式 0)
* 当模式时钟达到 HBLANK_CYCLES 时,递增扫描线,并根据扫描线数量决定进入 VBLANK 或下一条扫描线
*/
handleHBlankMode() {
if (this.modeClock >= GPU_TIMINGS.HBLANK_CYCLES) {
this.modeClock = 0;
this.currentLine++; // 扫描线递增

if (this.currentLine === GPU_TIMINGS.VISIBLE_LINES) {
// 所有可见扫描线完成(0-143),进入垂直消隐模式
this.mode = GPU_MODES.VBLANK;
this.onFrameComplete(); // 通知一帧渲染完成
} else {
// 继续下一条扫描线,回到 OAM 搜索模式
this.mode = GPU_MODES.OAM_SEARCH;
}
}
}

/**
* 处理垂直消隐模式(模式 1)
* VBLANK 持续 10 条扫描线的时间。当模式时钟达到 VBLANK_LINE_CYCLES 时,递增扫描线,
* 直到所有 VBLANK 扫描线完成,然后开始新的一帧。
*/
handleVBlankMode() {
if (this.modeClock >= GPU_TIMINGS.VBLANK_LINE_CYCLES) {
this.modeClock = 0;
this.currentLine++; // 扫描线递增

if (this.currentLine > GPU_TIMINGS.TOTAL_LINES - 1) {
// 垂直消隐结束(总共 154 条扫描线),开始新的一帧
this.currentLine = 0;
this.mode = GPU_MODES.OAM_SEARCH;
this.onNewFrameStart(); // 通知新帧开始
}
}
}

/**
* OAM 搜索逻辑(占位函数)
* 在第 7 章(精灵)中会实现完整的精灵处理逻辑
*/
searchOAM() {
// TODO: 实现精灵搜索逻辑
// 1. 扫描 OAM 表中的 40 个精灵
// 2. 找出在当前扫描线上的精灵(最多 10 个)
// 3. 按 X 坐标排序准备渲染
}

/**
* 渲染当前扫描线
* TODO: 目前是一个简单的测试渲染,在第 4 章(图形)中会实现完整的背景和精灵渲染
*/
renderScanline() {
this.renderTestPattern(); // 渲染一个测试图案
}

/**
* 渲染测试图案(用于验证 GPU 时序)
* 在 Canvas 上显示彩色条纹,以验证扫描线渲染是否正常工作
*/
renderTestPattern() {
const data = this.frameBuffer.data;
const y = this.currentLine; // 当前要渲染的扫描线

// 为每条扫描线生成不同颜色的测试图案
for (let x = 0; x < DISPLAY_CONFIG.WIDTH; x++) {
const pixelIndex = (y * DISPLAY_CONFIG.WIDTH + x) * 4;

// 创建彩色测试图案,使之随帧动画
const colorPhase = (x + y + this.frameCount) % 64;

if (colorPhase < 16) {
// 红色渐变
data[pixelIndex] = 255;
data[pixelIndex + 1] = colorPhase * 16;
data[pixelIndex + 2] = colorPhase * 16;
} else if (colorPhase < 32) {
// 绿色渐变
data[pixelIndex] = (32 - colorPhase) * 16;
data[pixelIndex + 1] = 255;
data[pixelIndex + 2] = (colorPhase - 16) * 16;
} else if (colorPhase < 48) {
// 蓝色渐变
data[pixelIndex] = (colorPhase - 32) * 16;
data[pixelIndex + 1] = (48 - colorPhase) * 16;
data[pixelIndex + 2] = 255;
} else {
// 白色渐变
const brightness = (64 - colorPhase) * 16;
data[pixelIndex] = brightness;
data[pixelIndex + 1] = brightness;
data[pixelIndex + 2] = brightness;
}

data[pixelIndex + 3] = 255; // Alpha(完全不透明)
}
}

/**
* 帧渲染完成回调
* 当一帧的所有可见扫描线渲染完成时调用。在这里我们将帧缓冲区显示到 Canvas。
*/
onFrameComplete() {
// 将帧缓冲区内容显示到 Canvas
this.updateDisplay();

// 更新帧计数和性能统计
this.frameCount++;
this.updatePerformanceStats();

// TODO: 触发垂直消隐中断(在第 8 章中断中实现)
this.triggerVBlankInterrupt();

console.log(`帧 ${this.frameCount} 渲染完成`);
}

/**
* 新帧开始回调
* 当垂直消隐期结束,准备开始新的一帧渲染时调用。
*/
onNewFrameStart() {
// TODO: 为新帧做准备,例如:
// 1. 重置精灵计数器
// 2. 更新背景/窗口滚动寄存器
// 3. 处理显示控制寄存器变化
}

/**
* 将帧缓冲区内容更新到 Canvas
*/
updateDisplay() {
this.context.putImageData(this.frameBuffer, 0, 0);
}

/**
* 更新性能统计,例如 FPS
*/
updatePerformanceStats() {
const currentTime = performance.now();
const frameTime = currentTime - this.lastFrameTime;
this.lastFrameTime = currentTime;

// 计算 FPS
const fps = 1000 / frameTime;

// 每 60 帧输出一次性能信息,避免频繁日志输出
if (this.frameCount % 60 === 0) {
console.log(`性能统计 - FPS: ${fps.toFixed(2)}, 帧时间: ${frameTime.toFixed(2)}ms`);
}
}

/**
* 触发垂直消隐中断(占位函数)
* 在第 8 章(中断)中会实现完整的中断系统,通知 CPU 进行 VBLANK 相关操作
*/
triggerVBlankInterrupt() {
// TODO: 实现 V-Blank 中断
// 1. 设置中断标志位(例如在 MMU 中设置 IE/IF 寄存器)
// 2. 如果中断使能,通知 CPU 暂停当前执行并跳转到中断处理程序
}

/**
* 获取当前 GPU 状态信息(用于调试)
* @returns {Object} GPU 状态对象,包含模式、时钟、扫描线等
*/
getStatus() {
return {
mode: this.mode,
modeName: this.getModeName(this.mode),
modeClock: this.modeClock,
currentLine: this.currentLine,
totalClock: this.totalClock,
frameCount: this.frameCount,
fps: this.calculateFPS()
};
}

/**
* 获取 GPU 模式名称的辅助函数
* @param {number} mode - 模式号
* @returns {string} 模式名称
*/
getModeName(mode) {
const modeNames = {
[GPU_MODES.HBLANK]: '水平消隐',
[GPU_MODES.VBLANK]: '垂直消隐',
[GPU_MODES.OAM_SEARCH]: 'OAM 搜索',
[GPU_MODES.PIXEL_TRANSFER]: '像素传输'
};
return modeNames[mode] || '未知模式';
}

/**
* 计算当前 FPS
* @returns {number} 当前 FPS 值
*/
calculateFPS() {
const currentTime = performance.now();
const timeDiff = currentTime - this.lastFrameTime;
return timeDiff > 0 ? 1000 / timeDiff : 0;
}

/**
* 获取调试信息字符串
* @returns {string} 格式化的调试信息
*/
getDebugInfo() {
const status = this.getStatus();
return `GPU 状态:
模式:${status.modeName}${status.mode}
模式时钟:${status.modeClock}
当前扫描线:${status.currentLine}
总时钟:${status.totalClock}
帧计数:${status.frameCount}
FPS:${status.fps.toFixed(2)}`;
}

4. 图形渲染:绘制背景与瓦片

与现代显卡动辄数 GB 的显存不同,早期游戏机(如 GameBoy)的内存极其有限,无法在内存中直接存储完整的屏幕像素(即帧缓冲区)。为了解决这个问题,工程师们采用了一种非常聪明的技术——瓦片系统。

想象一下你在用瓷砖来铺地板。你不需要为每一块地面都设计一个独一无二的图案,而是可以使用一组预先设计好的瓷砖(瓦片),通过不同的排列组合来创造出丰富的地面样式。GameBoy 的图形渲染就是基于这个原理。我们只需要存储一份“模板”,然后在需要的地方“引用”它即可。这个“模板”就是瓦片。

  • 瓦片:一个 8x8 像素的基本图形单元。游戏中的所有背景和角色(精灵)都是由这些小小的瓦片拼接而成的
  • 瓦片地图:一个 32x32 的二维数组,其中每个元素都是一个指向特定瓦片的索引。它就像一张设计图纸,规定了哪个位置应该使用哪个瓦片
  • 视图:GameBoy 屏幕只有 160x144 像素,而整个背景地图却有 256x256 像素(32个瓦片 × 8像素/瓦片)。屏幕就像一个“摄像机”,我们只能通过这个 160x144 的窗口看到庞大背景地图的一部分

做过游戏开发的,尤其是做像素风格的应该对这个都很熟悉吧。

还是不太理解的,可以想一下拼图。

你要拼一幅大画,瓦片是你的拼图块(8x8 像素的小方块)、瓦片地图是拼图说明书、视图是拼图框。

游戏背景制作过程:

  1. 美术画了很多 8x8 的小图块
  2. 策划说:“草地用 1 号块、石头用 2 号块……”
  3. 程序:“我按照说明书拼。”(GPU 渲染)
  4. 玩家在游戏中看到森林

1989 年光是 1MB 内存就动不动要几千块钱,直接存储一张完整图片作为背景实在是过于奢侈。使用瓦片系统不仅可以节省内存,也可以节省空间。整个游戏卡带也才 32KB,必须得精打细算。

VRAM(视频内存)中,瓦片数据和瓦片地图的布局如下:

地址区域 用途
0x8000 - 0x8FFF 瓦片集 #1
0x9000 - 0x97FF 瓦片集 #2
0x9800 - 0x9BFF 瓦片地图 #0
0x9C00 - 0x9FFF 瓦片地图 #1

有趣的是,瓦片集 #0 和 #1 有一部分是重叠的。游戏可以通过设置寄存器来选择使用哪个瓦片集和哪个瓦片地图,从而实现不同的显示效果。

4.1. 背景滚动与调色板

既然背景地图比屏幕大,我们自然就可以通过移动“摄像机”的位置来实现背景滚动的效果。这在平台跳跃或飞行射击游戏中非常常见。

GameBoy 提供了两个特殊的 GPU 寄存器来控制滚动:

  • SCY(Scroll Y, 地址 0xFF42):定义了屏幕顶边对应在 256x256 背景地图上的垂直坐标
  • SCX(Scroll X, 地址 0xFF43):定义了屏幕左边对应在 256x256 背景地图上的水平坐标

通过在每一帧之间改变 SCXSCY 的值,游戏就能让背景平滑地滚动起来,创造出动态的世界。

或许大家都会以为 GameBoy 是纯黑白的,但实际上它可以显示四种深浅不同的“灰色”(或者说是绿色,取决于屏幕材质)。这四种颜色是通过调色板系统实现的。

一个瓦片中的每个像素用 2 个比特来表示,可以表示四种值(00, 01, 10, 11)。但这四个值具体对应哪种颜色,是由背景调色板寄存器(BGP, 地址 0xFF47)决定的。

BGP 是一个 8 位寄存器,每 2 位定义一个颜色的映射关系:

  • 位 1-0:映射像素值 00
  • 位 3-2:映射像素值 01
  • 位 5-4:映射像素值 10
  • 位 7-6:映射像素值 11

例如,如果 BGP 的值是 0xE4(二进制 11100100),那么映射关系就是:

  • 0000(颜色 0)
  • 0101(颜色 1)
  • 1010(颜色 2)
  • 1111(颜色 3)

这是最常见的默认调色板。但如果游戏将 BGP 的值改为 0x1E(二进制 00011011),映射就会变成:

  • 0011(颜色 3)
  • 0110(颜色 2)
  • 1001(颜色 1)
  • 1100(颜色 0)

这样整个屏幕的颜色就瞬间反转了,能够很高效地实现特殊视觉效果(比方说闪烁、水下效果)的方式。

在我们的模拟器中,我们将这四种颜色定义为经典的 GameBoy 绿色调:

graphics.jsjavascript
1
2
3
4
5
6
const DEFAULT_PALETTE = [
[155, 188, 15, 255], // 颜色 0: 最亮 (白)
[139, 172, 15, 255], // 颜色 1: 亮灰 (浅绿)
[48, 98, 48, 255], // 颜色 2: 暗灰 (深绿)
[15, 56, 15, 255] // 颜色 3: 最暗 (黑)
];

4.2. 瓦片数据结构与缓存管理

在 GameBoy 中,每个瓦片的像素数据以一种特殊的方式存储。每个像素需要 2 位来表示(因为有 4 种颜色),但这 2 位并不是连续存储的。相反,一个瓦片行的 8 个像素的低位全部存储在一个字节中,高位存储在下一个字节中。

例如,如果一个瓦片行的像素值是 [3, 2, 1, 0, 0, 1, 2, 3],那么:

  • 低位字节:11100001(每个像素值的最低位)
  • 高位字节:11000011(每个像素值的最高位)

这种存储方式虽然看起来复杂,但符合 GameBoy 硬件的读取方式。

这一部分不需要完全理解,只要知道“GameBoy 有自己的存储格式,我们需要转换一下”就够了。

为了提高模拟器的性能,我们需要将这种原始格式转换为更易于处理的格式,并建立缓存机制:

graphics.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 瓦片管理器类
* 负责瓦片数据的存储、更新和缓存管理
*/
class TileManager {
constructor() {
// 扩展瓦片数据缓存 - 每个瓦片预计算为 8x8 的像素数组
this.tileCache = new Array(GRAPHICS_CONSTANTS.TOTAL_TILES);

for (let i = 0; i < GRAPHICS_CONSTANTS.TOTAL_TILES; i++) {
this.tileCache[i] = new Array(GRAPHICS_CONSTANTS.TILE_HEIGHT);
for (let y = 0; y < GRAPHICS_CONSTANTS.TILE_HEIGHT; y++) {
this.tileCache[i][y] = new Uint8Array(GRAPHICS_CONSTANTS.TILE_WIDTH);
}
}

// 标记哪些瓦片需要更新
this.dirtyTiles = new Set();
}

/**
* 更新单个瓦片的缓存数据
*/
updateTileCache(tileIndex) {
// 计算瓦片在 VRAM 中的起始地址
const baseAddr = tileIndex * GRAPHICS_CONSTANTS.TILE_SIZE_BYTES;

// 逐行处理瓦片数据
for (let y = 0; y < GRAPHICS_CONSTANTS.TILE_HEIGHT; y++) {
const rowAddr = baseAddr + (y * 2); // 每行占 2 字节

if (rowAddr + 1 < this.vram.length) {
const lowByte = this.vram[rowAddr]; // 像素低位
const highByte = this.vram[rowAddr + 1]; // 像素高位

// 从两个字节中提取 8 个像素
for (let x = 0; x < GRAPHICS_CONSTANTS.TILE_WIDTH; x++) {
const bitMask = 1 << (7 - x);

const lowBit = (lowByte & bitMask) ? 1 : 0;
const highBit = (highByte & bitMask) ? 2 : 0;

// 组合成 2 位颜色值
this.tileCache[tileIndex][y][x] = lowBit + highBit;
}
}
}
}
}

这个缓存系统的核心思想是“按需更新”——只有当 VRAM 中的瓦片数据发生变化时,我们才重新计算对应的缓存。这样可以避免每次渲染时都进行耗时的位操作。

4.3. 调色板系统实现

调色板管理器负责处理颜色映射。它不仅存储当前的调色板设置,还提供了动态更新调色板的能力:

graphics.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 调色板管理器类
* 处理 GameBoy 的 4 色调色板系统
*/
class PaletteManager {
constructor() {
// 背景调色板(4 种颜色,每种颜色 RGBA 4 字节)
this.backgroundPalette = new Array(GRAPHICS_CONSTANTS.PALETTE_COLORS);

// 原始调色板寄存器值(用于模拟硬件寄存器)
this.paletteRegister = 0xFC; // 默认值:11 10 01 00

this.setDefaultPalette();
}

/**
* 更新调色板寄存器
*/
updatePaletteRegister(value) {
this.paletteRegister = value & 0xFF;

// 从寄存器值提取 4 个 2 位颜色映射
for (let i = 0; i < GRAPHICS_CONSTANTS.PALETTE_COLORS; i++) {
const colorIndex = (this.paletteRegister >> (i * 2)) & 0x03;
this.backgroundPalette[i] = [...DEFAULT_PALETTE[colorIndex]];
}
}

/**
* 获取映射后的颜色
*/
getColor(paletteIndex) {
if (paletteIndex < 0 || paletteIndex >= GRAPHICS_CONSTANTS.PALETTE_COLORS) {
return DEFAULT_PALETTE[0];
}

return this.backgroundPalette[paletteIndex];
}
}

例如,当游戏写入调色板寄存器 0xFF47 时,updatePaletteRegister 方法会被调用,自动重新映射所有颜色。这意味着游戏可以通过一个简单的寄存器写入操作瞬间改变整个屏幕的色调。

4.4. 背景滚动控制

滚动控制器管理背景的偏移位置,这是实现动态背景效果的关键:

graphics.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 滚动控制器类
* 管理背景的滚动位置
*/
class ScrollController {
constructor() {
this.scrollX = 0; // 水平滚动位置
this.scrollY = 0; // 垂直滚动位置
}

/**
* 设置滚动位置
*/
setScroll(x, y) {
this.scrollX = x & 0xFF;
this.scrollY = y & 0xFF;
}

/**
* 获取当前滚动位置
*/
getScrollPosition() {
return {
x: this.scrollX,
y: this.scrollY
};
}
}

这个简单的类封装了 GameBoy 的 SCXSCY 寄存器的功能。当游戏修改这些寄存器时,滚动位置会立即更新,下一帧的渲染就会反映出新的滚动位置。

4.5. 扫描线级背景渲染

背景渲染器是整个图形系统的核心,它负责将瓦片地图转换为实际的像素数据。渲染是按扫描线进行的,这模拟了 GameBoy 的真实渲染方式:

graphics.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 背景渲染器类
* 负责将瓦片地图渲染到帧缓冲区
*/
class BackgroundRenderer {
/**
* 渲染单条扫描线的背景
*/
renderBackgroundScanline(lineNumber, frameBuffer) {
// 获取滚动位置
const scroll = this.scrollController.getScrollPosition();

// 计算当前扫描线在瓦片地图中的 Y 位置
const mapY = (lineNumber + scroll.y) & 0xFF;
const tileY = Math.floor(mapY / GRAPHICS_CONSTANTS.TILE_HEIGHT);
const pixelY = mapY % GRAPHICS_CONSTANTS.TILE_HEIGHT;

// 计算瓦片地图行的起始偏移
const mapBaseAddr = this.backgroundMapSelect ?
VRAM_LAYOUT.TILEMAP_1_START : VRAM_LAYOUT.TILEMAP_0_START;
const mapOffset = mapBaseAddr - VRAM_LAYOUT.TILESET_1_START;
const mapLineOffset = mapOffset + (tileY * GRAPHICS_CONSTANTS.MAP_WIDTH_TILES);

// 计算起始瓦片的 X 位置
let mapX = scroll.x;
let tileX = Math.floor(mapX / GRAPHICS_CONSTANTS.TILE_WIDTH);
let pixelX = mapX % GRAPHICS_CONSTANTS.TILE_WIDTH;

// 计算帧缓冲区的起始位置
let canvasOffset = lineNumber * GRAPHICS_CONSTANTS.SCREEN_WIDTH * 4;

// 获取第一个瓦片
let tileIndex = this.getTileIndex(mapLineOffset + tileX);
let tileData = this.tileManager.getTileData(tileIndex);

// 渲染整条扫描线(160 像素)
for (let screenX = 0; screenX < GRAPHICS_CONSTANTS.SCREEN_WIDTH; screenX++) {
// 获取当前像素的调色板索引
const paletteIndex = tileData ? tileData[pixelY][pixelX] : 0;

// 通过调色板获取最终颜色
const color = this.paletteManager.getColor(paletteIndex);

// 写入帧缓冲区
frameBuffer.data[canvasOffset] = color[0]; // R
frameBuffer.data[canvasOffset + 1] = color[1]; // G
frameBuffer.data[canvasOffset + 2] = color[2]; // B
frameBuffer.data[canvasOffset + 3] = color[3]; // A
canvasOffset += 4;

// 移动到下一个像素
pixelX++;
if (pixelX >= GRAPHICS_CONSTANTS.TILE_WIDTH) {
// 移动到下一个瓦片
pixelX = 0;
tileX = (tileX + 1) & 31; // 瓦片地图是 32x32,循环
tileIndex = this.getTileIndex(mapLineOffset + tileX);
tileData = this.tileManager.getTileData(tileIndex);
}
}
}
}

这个渲染过程的关键在于理解坐标转换:

  1. 从屏幕坐标转换为背景地图坐标(考虑滚动偏移)
  2. 从背景地图坐标转换为瓦片坐标和瓦片内像素坐标
  3. 从瓦片地图读取瓦片索引
  4. 从瓦片缓存读取像素的调色板索引
  5. 通过调色板获取最终的 RGBA 颜色值

4.6. 图形系统集成

最后,图形系统主类将所有组件协调起来工作:

graphics.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
/**
* 图形系统主类
* 协调所有图形组件的工作
*/
class GameBoyGraphicsSystem {
constructor() {
// 创建各个组件
this.tileManager = new TileManager();
this.paletteManager = new PaletteManager();
this.scrollController = new ScrollController();
this.backgroundRenderer = new BackgroundRenderer(
this.tileManager,
this.paletteManager,
this.scrollController
);
}

/**
* 连接到 MMU 的 VRAM
*/
connectVRAM(vramData) {
this.tileManager.setVRAM(vramData);
this.backgroundRenderer.setVRAM(vramData);
}

/**
* 处理图形寄存器写入
*/
writeGraphicsRegister(register, value) {
switch (register) {
case 0xFF42: // SCY - 垂直滚动
this.scrollController.setScrollY(value);
break;

case 0xFF43: // SCX - 水平滚动
this.scrollController.setScrollX(value);
break;

case 0xFF47: // BGP - 背景调色板
this.paletteManager.updatePaletteRegister(value);
break;
}
}

/**
* 渲染单条扫描线
*/
renderScanline(lineNumber, frameBuffer) {
if (!this.enabled || !this.backgroundEnabled) {
return;
}

this.backgroundRenderer.renderBackgroundScanline(lineNumber, frameBuffer);
}
}

这个集成系统的设计遵循了模块化原则——每个组件都有清晰的职责,通过明确的接口进行通信。当 GPU 需要渲染一条扫描线时,它只需调用 renderScanline 方法,图形系统会自动协调所有子组件完成复杂的渲染过程。

4.7. 图形系统集成与性能优化

在完成了瓦片管理、调色板和背景渲染等核心组件后,我们需要将图形系统与现有的 GPU 时序系统集成起来,并进行性能优化以确保流畅的渲染效果(当时测试的时候 FPS 发现连 1 都不到……)。

首先,我们需要在 GPU 类中添加图形系统的初始化和管理逻辑:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 初始化图形系统
*/
initializeGraphicsSystem() {
// 图形系统实例(如果可用)
this.graphicsSystem = null;
this.renderMode = 'test'; // 'test' 或 'graphics'

// 尝试初始化图形系统
if (typeof GameBoyGraphicsSystem !== 'undefined') {
try {
this.graphicsSystem = new GameBoyGraphicsSystem();
this.renderMode = 'graphics';
console.log('图形系统已集成到 GPU');
} catch (error) {
console.warn('图形系统初始化失败,使用测试模式:', error);
}
} else {
console.log('图形系统未加载,使用测试渲染模式');
}
}

接下来,我们需要建立 GPU 与图形系统之间的数据连接:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 连接到 MMU 的 VRAM
*/
connectVRAM(vramData) {
if (this.graphicsSystem) {
this.graphicsSystem.connectVRAM(vramData);
console.log('GPU 已连接到 VRAM');
}
}

/**
* 处理 VRAM 写入事件
*/
onVRAMWrite(address, value) {
if (this.graphicsSystem) {
this.graphicsSystem.onVRAMWrite(address, value);
}
}

/**
* 处理图形寄存器写入
*/
writeRegister(register, value) {
if (this.graphicsSystem) {
this.graphicsSystem.writeGraphicsRegister(register, value);
}
}

为了提供更好的调试体验和性能,我们要实现渲染模式切换:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 渲染真实的 GameBoy 背景图形
*/
renderGameBoyGraphics() {
if (window.GameBoyGraphicsSystem && this.graphicsSystem) {
this.graphicsSystem.renderScanline(this.currentLine, this.frameBuffer);
} else {
// 否则使用测试图案
this.renderTestPattern();
}
}

/**
* 切换渲染模式
*/
setRenderMode(mode) {
if (mode === 'graphics' && !this.graphicsSystem) {
console.warn('图形系统未初始化,无法切换到图形模式');
return;
}

this.renderMode = mode;
console.log(`渲染模式已切换到:${mode}`);
}

/**
* 渲染当前扫描线
*/
renderScanline() {
// 根据渲染模式选择渲染方式
if (this.renderMode === 'graphics' && this.graphicsSystem) {
this.renderGameBoyGraphics();
} else {
this.renderTestPattern();
}
}

最重要的性能优化是引入了简化的帧级别渲染,避免了复杂的周期级别计算:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 简化的帧级别步进
* 每次调用渲染一整帧,避免复杂的周期计算
*/
stepFrame() {
// 1. 渲染所有可见扫描线 (0-143)
for (let line = 0; line < GPU_TIMINGS.VISIBLE_LINES; line++) {
this.currentLine = line;
this.mode = GPU_MODES.PIXEL_TRANSFER;
this.renderScanline();
}

// 2. 模拟垂直消隐期间 (144-153)
this.mode = GPU_MODES.VBLANK;
this.currentLine = GPU_TIMINGS.VISIBLE_LINES;

// 3. 完成帧渲染
this.onFrameComplete();

// 4. 重置到下一帧开始
this.currentLine = 0;
this.mode = GPU_MODES.OAM_SEARCH;
this.modeClock = 0;
}

5. 系统集成:构建完整的模拟器架构

在前面的章节中,我们分别实现了 CPU 指令处理、MMU 内存管理、GPU 时序控制和图形渲染系统。虽然这些组件各自功能完善,但它们就跟分散的乐器差不多,需要一个指挥家来协调,不然是无法演奏出和谐的交响乐的。这一章的核心任务就是建立这样一个统一的系统架构,让所有硬件组件无缝协作。

5.1. 硬件抽象层与组件连接

在真实的 GameBoy 中,各个硬件组件通过物理总线连接。在我们的模拟器中,我们需要建立一个软件层面的“总线系统“,让组件间能够进行通信。MMU 作为内存管理的中心,天然地成为了这个连接中心。

我们为 MMU 添加硬件组件连接能力:

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 初始化硬件组件引用
*/
initializeHardwareReferences() {
// 硬件组件引用,由外部注入
this.gpu = null;
this.inputController = null; // 第6章添加
this.timer = null; // 第10章添加
this.interruptController = null; // 第8章添加

console.log('📡 MMU 硬件接口已初始化');
}

/**
* 连接硬件组件
* @param {string} componentType 组件类型('gpu', 'input', 'timer', 'interrupt')
* @param {Object} component 硬件组件实例
*/
connectHardware(componentType, component) {
switch (componentType) {
case 'gpu':
this.gpu = component;
console.log('✅ GPU 已连接到 MMU');
break;
case 'input':
this.inputController = component;
console.log('✅ 输入控制器已连接到 MMU');
break;
// ... 其他组件
}
}

这种设计的优势在于松耦合:每个组件都不需要知道其他组件的具体实现,只需要通过 MMU 这个“中介”进行通信。当我们要添加新的硬件组件时,只需要在 MMU 中注册即可。

5.2. I/O 寄存器路由系统

GameBoy 的硬件组件通过内存映射的 I/O 寄存器进行控制。这些寄存器位于 0xFF00-0xFF7F 地址范围内,不同的地址控制不同的硬件功能。我们需要建立一个路由系统,将对这些地址的读写操作转发给相应的硬件组件。

首先,定义一个完整的寄存器地址映射:

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* I/O 寄存器映射常量
*/
const IO_REGISTERS = {
// GPU 寄存器范围(0xFF40-0xFF7F)
GPU_START: 0xFF40,
GPU_END: 0xFF7F,

// 具体的 GPU 寄存器
LCDC: 0xFF40, // LCD 控制寄存器
STAT: 0xFF41, // LCD 状态寄存器
SCY: 0xFF42, // 垂直滚动
SCX: 0xFF43, // 水平滚动
LY: 0xFF44, // 当前扫描线(只读)
BGP: 0xFF47, // 背景调色板

// 其他硬件寄存器
JOYPAD: 0xFF00, // 按键输入(第6章实现)
IF: 0xFF0F, // 中断标志
IE: 0xFFFF // 中断使能
};

MMU 中的寄存器读写方法会根据地址范围自动将请求路由到对应的硬件组件:

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 连接硬件组件
* @param {string} componentType - 组件类型 ('gpu', 'input', 'timer', 'interrupt')
* @param {Object} component - 硬件组件实例
*/
connectHardware(componentType, component) {
switch (componentType) {
case 'gpu':
this.gpu = component;
console.log('✅ GPU 已连接到 MMU');
break;
case 'input':
this.inputController = component;
console.log('✅ 输入控制器已连接到 MMU');
break;
case 'timer':
this.timer = component;
console.log('✅ 定时器已连接到 MMU');
break;
case 'interrupt':
this.interruptController = component;
console.log('✅ 中断控制器已连接到 MMU');
break;
default:
console.warn(`⚠️ 未知的硬件组件类型: ${componentType}`);
}
}

不同的寄存器地址控制不同的硬件功能,MMU 需要将读写操作正确路由到对应的硬件组件:

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
* 读取I/O寄存器 - 支持硬件组件路由
*/
readIORegister(address) {
// 🎨 GPU 寄存器范围 (0xFF40-0xFF7F)
if (address >= IO_REGISTERS.GPU_START && address <= IO_REGISTERS.GPU_END) {
if (this.gpu && typeof this.gpu.readRegister === 'function') {
return this.gpu.readRegister(address);
} else {
console.warn(`⚠️ GPU 未连接,无法读取寄存器 0x${address.toString(16)}`);
return 0xFF;
}
}

// TODO: 以下都会在未来的章节中实现

// 🎮 按键输入寄存器 (0xFF00)
if (address === IO_REGISTERS.JOYPAD) {
if (this.inputController && typeof this.inputController.readJoypadRegister === 'function') {
return this.inputController.readJoypadRegister();
}
return 0xFF; // 默认:所有按键未按下
}

// ⏰ 定时器寄存器 (0xFF04-0xFF07)
if (address >= IO_REGISTERS.TIMER_DIV && address <= IO_REGISTERS.TIMER_TAC) {
if (this.timer && typeof this.timer.readRegister === 'function') {
return this.timer.readRegister(address);
}
return 0x00;
}

// 🔔 中断寄存器 (0xFF0F, 0xFFFF)
if (address === IO_REGISTERS.IF || address === IO_REGISTERS.IE) {
if (this.interruptController && typeof this.interruptController.readRegister === 'function') {
return this.interruptController.readRegister(address);
}
return 0x00;
}

// 默认处理
const registerIndex = address - MEMORY_REGIONS.IO_START;
return this.ioRegisters[registerIndex] || 0xFF;
}

对应的写入路由逻辑确保了每个硬件组件都能及时收到控制指令:

mmu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
* 写入I/O寄存器 - 支持硬件组件路由
* @param {number} address - I/O寄存器地址
* @param {number} value - 要写入的值
*/
writeIORegister(address, value) {
// 🎨 GPU 寄存器范围 (0xFF40-0xFF7F)
if (address >= IO_REGISTERS.GPU_START && address <= IO_REGISTERS.GPU_END) {
if (this.gpu && typeof this.gpu.writeRegister === 'function') {
this.gpu.writeRegister(address, value);
} else {
console.warn(`⚠️ GPU 未连接,无法写入寄存器 0x${address.toString(16)}`);
}
// 同时保存到本地数组(用于后备)
const registerIndex = address - MEMORY_REGIONS.IO_START;
if (registerIndex >= 0 && registerIndex < this.ioRegisters.length) {
this.ioRegisters[registerIndex] = value;
}
return;
}

// 🎮 按键输入寄存器 (0xFF00) - 第6章实现
if (address === IO_REGISTERS.JOYPAD) {
if (this.inputController && typeof this.inputController.writeJoypadRegister === 'function') {
this.inputController.writeJoypadRegister(value);
}
// 保存到本地数组
this.ioRegisters[address - MEMORY_REGIONS.IO_START] = value;
return;
}

// ⏰ 定时器寄存器 (0xFF04-0xFF07) - 第10章实现
if (address >= IO_REGISTERS.TIMER_DIV && address <= IO_REGISTERS.TIMER_TAC) {
if (this.timer && typeof this.timer.writeRegister === 'function') {
this.timer.writeRegister(address, value);
}
this.ioRegisters[address - MEMORY_REGIONS.IO_START] = value;
return;
}

// 🔔 中断寄存器 (0xFF0F, 0xFFFF) - 第8章实现
if (address === IO_REGISTERS.IF || address === IO_REGISTERS.IE) {
if (this.interruptController && typeof this.interruptController.writeRegister === 'function') {
this.interruptController.writeRegister(address, value);
}
// 特殊处理:IE 寄存器在零页RAM中
if (address === IO_REGISTERS.IE) {
this.zram[0x7F] = value; // 0xFFFF -> ZRAM[0x7F]
} else {
this.ioRegisters[address - MEMORY_REGIONS.IO_START] = value;
}
return;
}

// 默认处理:保存到本地寄存器数组
const registerIndex = address - MEMORY_REGIONS.IO_START;
if (registerIndex >= 0 && registerIndex < this.ioRegisters.length) {
this.ioRegisters[registerIndex] = value;
} else {
console.warn(`⚠️ 无效的I/O寄存器写入: 0x${address.toString(16).padStart(4, '0')}`);
}
}

这样,当游戏代码写入 0xFF40(LCD 控制寄存器)时,MMU 会自动将这个写入操作转发给 GPU,GPU 就能实时更新其内部状态。

GPU 是 I/O 寄存器最密集的硬件组件,拥有十多个不同功能的寄存器。这些寄存器不仅控制显示行为,还影响中断、DMA 传输等关键功能。

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* GPU 寄存器常量定义
*/
const GPU_REGISTERS = {
// LCD 控制和状态
LCDC: 0xFF40, // LCD 控制寄存器
STAT: 0xFF41, // LCD 状态寄存器

// 滚动位置
SCY: 0xFF42, // 垂直滚动
SCX: 0xFF43, // 水平滚动

// 扫描线相关
LY: 0xFF44, // 当前扫描线(只读)
LYC: 0xFF45, // 扫描线比较值

// DMA 传输
DMA: 0xFF46, // DMA 传输寄存器

// 调色板
BGP: 0xFF47, // 背景调色板
OBP0: 0xFF48, // 精灵调色板 0
OBP1: 0xFF49, // 精灵调色板 1

// 窗口位置
WY: 0xFF4A, // 窗口 Y 位置
WX: 0xFF4B // 窗口 X 位置
};

/**
* LCD 控制寄存器标志位
*/
const LCDC_FLAGS = {
LCD_ENABLE: 0x80, // 位 7:LCD 开关
WINDOW_TILEMAP: 0x40, // 位 6:窗口瓦片地图选择
WINDOW_ENABLE: 0x20, // 位 5:窗口开关
BG_TILESET: 0x10, // 位 4:背景瓦片数据选择
BG_TILEMAP: 0x08, // 位 3:背景瓦片地图选择
SPRITE_SIZE: 0x04, // 位 2:精灵大小(0=8x8, 1=8x16)
SPRITE_ENABLE: 0x02, // 位 1:精灵开关
BG_ENABLE: 0x01 // 位 0:背景开关
};

GPU 内部维护所有寄存器的当前状态:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 初始化寄存器状态
*/
initializeRegisters() {
// GPU 寄存器状态
this.registers = {
// LCD 控制寄存器 (0xFF40)
lcdc: 0x00,

// LCD 状态寄存器 (0xFF41)
stat: 0x00,

// 滚动寄存器
scy: 0x00, // 垂直滚动 (0xFF42)
scx: 0x00, // 水平滚动 (0xFF43)

// 扫描线寄存器
ly: 0x00, // 当前扫描线 (0xFF44) - 只读
lyc: 0x00, // 扫描线比较值 (0xFF45)

// 调色板寄存器
bgp: 0xFC, // 背景调色板 (0xFF47) - 默认值

// 窗口位置寄存器
wy: 0x00, // 窗口 Y 位置 (0xFF4A)
wx: 0x00 // 窗口 X 位置 (0xFF4B)
};

// 解析控制标志位到独立变量(向后兼容)
this.updateControlFlags();
}

LCD 控制寄存器(LCDC)是一个特殊的寄存器,它的每一位都控制不同的显示功能:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 从寄存器值更新控制标志位
*/
updateControlFlags() {
const lcdc = this.registers.lcdc;

// LCD 控制标志
this._lcdEnabled = (lcdc & LCDC_FLAGS.LCD_ENABLE) !== 0;
this._windowEnabled = (lcdc & LCDC_FLAGS.WINDOW_ENABLE) !== 0;
this._spritesEnabled = (lcdc & LCDC_FLAGS.SPRITE_ENABLE) !== 0;
this._backgroundEnabled = (lcdc & LCDC_FLAGS.BG_ENABLE) !== 0;

// 瓦片和地图选择
this._backgroundTileSet = (lcdc & LCDC_FLAGS.BG_TILESET) !== 0 ? 1 : 0;
this._backgroundTileMap = (lcdc & LCDC_FLAGS.BG_TILEMAP) !== 0 ? 1 : 0;
this._windowTileMap = (lcdc & LCDC_FLAGS.WINDOW_TILEMAP) !== 0 ? 1 : 0;
this._spriteSize = (lcdc & LCDC_FLAGS.SPRITE_SIZE) !== 0 ? 16 : 8;
}

GPU 提供完整的寄存器读写接口:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
/**
* 读取 GPU 寄存器
* @param {number} address - 寄存器地址
* @returns {number} 寄存器值
*/
readRegister(address) {
switch (address) {
// LCD 控制寄存器 (0xFF40)
case GPU_REGISTERS.LCDC:
return this.registers.lcdc;

// LCD 状态寄存器 (0xFF41)
case GPU_REGISTERS.STAT:
// 组合状态标志位和当前模式
let stat = this.registers.stat & 0xF8; // 保留高 5 位
stat |= (this.mode & 0x03); // 添加当前模式

// 检查 LYC=LY 标志
if (this.registers.ly === this.registers.lyc) {
stat |= STAT_FLAGS.LYC_FLAG;
}

return stat;

// 滚动寄存器
case GPU_REGISTERS.SCY:
return this.registers.scy;

case GPU_REGISTERS.SCX:
return this.registers.scx;

// 当前扫描线 (只读)
case GPU_REGISTERS.LY:
return this.registers.ly;

// 扫描线比较值
case GPU_REGISTERS.LYC:
return this.registers.lyc;

// DMA 寄存器 (写入专用,读取返回上次写入值)
case GPU_REGISTERS.DMA:
return this.registers.dma;

// 调色板寄存器
case GPU_REGISTERS.BGP:
return this.registers.bgp;

case GPU_REGISTERS.OBP0:
return this.registers.obp0;

case GPU_REGISTERS.OBP1:
return this.registers.obp1;

// 窗口位置寄存器
case GPU_REGISTERS.WY:
return this.registers.wy;

case GPU_REGISTERS.WX:
return this.registers.wx;

default:
console.warn(`⚠️ 尝试读取未知的 GPU 寄存器: 0x${address.toString(16).padStart(4, '0')}`);
return 0xFF;
}
}

/**
* 写入 GPU 寄存器
* @param {number} address - 寄存器地址
* @param {number} value - 要写入的值
*/
writeRegister(address, value) {
// 确保值在 8 位范围内
value &= 0xFF;

switch (address) {
// LCD 控制寄存器 (0xFF40)
case GPU_REGISTERS.LCDC:
this.registers.lcdc = value;
this.updateControlFlags();

// 当 LCD 被禁用时,重置一些状态
if (!this._lcdEnabled) {
this.currentLine = 0;
this.registers.ly = 0;
this.mode = GPU_MODES.HBLANK;
this.modeClock = 0;
}

console.log(`🎛️ LCD 控制寄存器更新: 0x${value.toString(16).padStart(2, '0')}`);
break;

// LCD 状态寄存器 (0xFF41) - 只有高 5 位可写
case GPU_REGISTERS.STAT:
this.registers.stat = (value & 0xF8) | (this.registers.stat & 0x07);
break;

// 滚动寄存器
case GPU_REGISTERS.SCY:
this.registers.scy = value;
// 通知图形系统滚动更新
if (this.graphicsSystem && this.graphicsSystem.scrollController) {
this.graphicsSystem.scrollController.setScrollY(value);
}
break;

case GPU_REGISTERS.SCX:
this.registers.scx = value;
// 通知图形系统滚动更新
if (this.graphicsSystem && this.graphicsSystem.scrollController) {
this.graphicsSystem.scrollController.setScrollX(value);
}
break;

// 当前扫描线 (只读寄存器,写入被忽略)
case GPU_REGISTERS.LY:
console.warn(`⚠️ 尝试写入只读寄存器 LY: 0x${value.toString(16)}`);
break;

// 扫描线比较值
case GPU_REGISTERS.LYC:
this.registers.lyc = value;
break;

// DMA 传输寄存器 (0xFF46)
case GPU_REGISTERS.DMA:
this.registers.dma = value;
this.performDMATransfer(value);
break;

// 背景调色板 (0xFF47)
case GPU_REGISTERS.BGP:
this.registers.bgp = value;
this.updateBackgroundPalette(value);
console.log(`🎨 背景调色板更新: 0x${value.toString(16).padStart(2, '0')}`);
break;

// 精灵调色板寄存器
case GPU_REGISTERS.OBP0:
this.registers.obp0 = value;
// TODO: 实现精灵调色板更新 (第7章)
break;

case GPU_REGISTERS.OBP1:
this.registers.obp1 = value;
// TODO: 实现精灵调色板更新 (第7章)
break;

// 窗口位置寄存器
case GPU_REGISTERS.WY:
this.registers.wy = value;
break;

case GPU_REGISTERS.WX:
this.registers.wx = value;
break;

default:
console.warn(`⚠️ 尝试写入未知的 GPU 寄存器: 0x${address.toString(16).padStart(4, '0')} = 0x${value.toString(16).padStart(2, '0')}`);
}
}

DMA(直接内存访问)是 GameBoy 的重要功能,允许快速复制精灵数据到 OAM:

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 执行 DMA 传输
* @param {number} sourceHigh - 源地址高字节
*/
performDMATransfer(sourceHigh) {
// DMA 传输:将 256 字节从 sourceHigh*0x100 复制到 OAM (0xFE00-0xFE9F)
const sourceAddr = sourceHigh << 8;

console.log(`🚚 DMA 传输: 0x${sourceAddr.toString(16).padStart(4, '0')} -> OAM`);

// TODO: 实现完整的 DMA 传输逻辑
// 需要从 MMU 读取数据并写入 OAM
// 在真实硬件中,这会锁定总线并需要特定的时序
}
gpu.jsjavascript
1
2
3
4
5
6
7
8
9
/**
* 更新背景调色板
* @param {number} paletteValue - 调色板寄存器值
*/
updateBackgroundPalette(paletteValue) {
if (this.graphicsSystem && this.graphicsSystem.paletteManager) {
this.graphicsSystem.paletteManager.updatePaletteRegister(paletteValue);
}
}

5.4. 系统调度器架构

现在我们有了能够相互通信的硬件组件,但还需要一个“指挥家”来协调它们的工作。这就是系统调度器 gameboy.js 的作用。

系统调度器作为顶层管理器,负责创建、连接和管理所有硬件组件:

gameboy.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**
* 系统常量定义
*/
const SYSTEM_CONSTANTS = {
// 时序常量
CPU_FREQUENCY: 4194304, // 4.194304 MHz
TARGET_FPS: 59.7, // GameBoy 目标帧率
FRAME_CYCLES: 70224, // 每帧的 CPU 周期数

// 系统状态
STATE_STOPPED: 'stopped',
STATE_RUNNING: 'running',
STATE_PAUSED: 'paused',
STATE_ERROR: 'error',

// 调度模式
SCHEDULE_FRAME: 'frame', // 帧级别调度(默认)
SCHEDULE_CYCLE: 'cycle', // 周期级别调度(精确)
SCHEDULE_BURST: 'burst' // 突发调度(性能优先)
};

/**
* GameBoy 主系统类
* 协调 CPU、MMU、GPU 和其他硬件组件的工作
*/
class GameBoySystem {
constructor() {
// 系统状态
this.state = SYSTEM_CONSTANTS.STATE_STOPPED;
this.scheduleMode = SYSTEM_CONSTANTS.SCHEDULE_FRAME;

// 硬件组件
this.cpu = null;
this.mmu = null;
this.gpu = null;
this.graphics = null;

// 调度器状态
this.runningInterval = null;
this.frameRequestId = null;

// 性能统计
this.stats = {
framesRendered: 0,
cyclesExecuted: 0,
startTime: 0,
currentFPS: 0,
averageFPS: 0,
errors: 0
};
}
}

系统调度器的初始化过程展示了完整的组件连接流程:

gameboy.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
/**
* 创建硬件组件实例
*/
async createHardwareComponents() {
// 创建 MMU(内存管理单元)
this.mmu = new GameBoyMMU();
console.log('✅ MMU 已创建');

// 创建 GPU(图形处理单元)
this.gpu = new GameBoyGPU();
console.log('✅ GPU 已创建');

// 获取图形系统引用
if (this.gpu.graphicsSystem) {
this.graphics = this.gpu.graphicsSystem;
console.log('✅ 图形系统已连接');
}

// 创建 CPU(中央处理器)
this.cpu = new GameBoyCPU();
console.log('✅ CPU 已创建');
}

/**
* 连接硬件组件
*/
connectComponents() {
// 连接 GPU 到 MMU
this.mmu.connectHardware('gpu', this.gpu);

// 连接 GPU 到 VRAM
if (this.mmu.vram) {
this.gpu.connectVRAM(this.mmu.vram);

// 连接图形系统到 VRAM
if (this.graphics) {
this.graphics.connectVRAM(this.mmu.vram);
}
}

console.log('🔗 硬件组件连接完成');
}

这个连接过程确保了:

  1. GPU 能够通过 MMU 接收寄存器访问
  2. GPU 能够直接访问 VRAM 进行渲染
  3. 图形系统能够接收 VRAM 更新通知

5.5. 帧级别时序调度

先前我们实现了精确的周期级别时序,但在实际运行中这种精确度往往会带来性能负担,因此我们当时引入了帧级别调度,在保持足够精度的同时大幅提升性能。现在我们完善一下当时只是简化版本的 stepFrame()

gpu.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 每次调用渲染一整帧,避免复杂的周期计算
*/
stepFrame() {
// 如果 LCD 被禁用,不渲染
if (!this._lcdEnabled) {
return;
}

// 1. 渲染所有可见扫描线 (0-143)
for (let line = 0; line < GPU_TIMINGS.VISIBLE_LINES; line++) {
this.currentLine = line;
this.registers.ly = line;
this.mode = GPU_MODES.PIXEL_TRANSFER;
this.renderScanline();
}

// 2. 模拟垂直消隐期间 (144-153)
this.mode = GPU_MODES.VBLANK;
this.currentLine = GPU_TIMINGS.VISIBLE_LINES;
this.registers.ly = GPU_TIMINGS.VISIBLE_LINES;

// 3. 完成帧渲染
this.onFrameComplete();

// 4. 重置到下一帧开始
this.currentLine = 0;
this.registers.ly = 0;
this.mode = GPU_MODES.OAM_SEARCH;
this.modeClock = 0;
}

系统调度器支持多种调度模式以适应不同的需求:

gameboy.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 帧级别调度循环(默认模式)
*/
startFrameLoop() {
const frameLoop = (currentTime) => {
if (this.state !== SYSTEM_CONSTANTS.STATE_RUNNING) {
return;
}

try {
// 计算时间差
const deltaTime = currentTime - this.lastFrameTime;

// 如果达到目标帧时间,执行一帧
if (deltaTime >= this.targetFrameTime) {
this.executeFrame();
this.lastFrameTime = currentTime;
this.updateFPS(deltaTime);
}

// 请求下一帧
this.frameRequestId = requestAnimationFrame(frameLoop);

} catch (error) {
this.handleError(error);
}
};

// 启动循环
this.frameRequestId = requestAnimationFrame(frameLoop);
}

/**
* 执行一帧的渲染
*/
executeFrame() {
// TODO: 简化的帧执行:直接让 GPU 渲染一帧
if (this.gpu) {
this.gpu.stepFrame();
}

// 更新统计
this.stats.framesRendered++;
this.stats.cyclesExecuted += SYSTEM_CONSTANTS.FRAME_CYCLES;
}

这种设计的优势是:

  • 性能优化:避免了复杂的周期级别计算
  • 稳定的帧率:使用 requestAnimationFrame 确保流畅的 60 FPS
  • 灵活性:可以根据需要切换到其他调度模式

5.6. 错误处理与系统监控

一个健壮的模拟器必须能够优雅地处理错误并提供丰富的监控信息。系统调度器实现了多层次的错误处理:

gameboy.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 错误处理
*/
handleError(error) {
this.stats.errors++;

console.error(`🚨 系统错误 #${this.stats.errors}:`, error);

// 如果错误过多,停止系统
if (this.stats.errors > 10) {
console.error('❌ 错误过多,停止系统运行');
this.stop();
this.state = SYSTEM_CONSTANTS.STATE_ERROR;
return;
}

// 尝试恢复
this.retryCount++;
if (this.retryCount < this.maxRetries) {
console.log(`🔄 尝试恢复系统 (${this.retryCount}/${this.maxRetries})`);
setTimeout(() => {
if (this.state === SYSTEM_CONSTANTS.STATE_ERROR) {
this.reset();
}
}, 1000);
} else {
console.error('❌ 恢复失败,系统进入错误状态');
this.state = SYSTEM_CONSTANTS.STATE_ERROR;
}
}

系统提供了丰富的性能统计信息:

gameboy.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/**
* 更新 FPS 计算
* @param {number} deltaTime - 帧时间差
*/
updateFPS(deltaTime) {
this.stats.currentFPS = deltaTime > 0 ? 1000 / deltaTime : 0;
}

/**
* 计算平均 FPS
*/
calculateAverageFPS() {
const totalTime = (performance.now() - this.stats.startTime) / 1000;
this.stats.averageFPS = totalTime > 0 ? this.stats.framesRendered / totalTime : 0;
}

/**
* 获取系统状态
*/
getStatus() {
const status = {
state: this.state,
scheduleMode: this.scheduleMode,
statistics: { ...this.stats },
components: {
cpu: this.cpu ? this.cpu.getStatusString() : '未初始化',
mmu: this.mmu ? this.mmu.getMemoryStats() : '未初始化',
gpu: this.gpu ? this.gpu.getStatus() : '未初始化'
}
};

// 计算实时统计
if (this.stats.startTime > 0) {
const totalTime = (performance.now() - this.stats.startTime) / 1000;
status.statistics.totalRunTime = totalTime;
status.statistics.averageFPS = totalTime > 0 ? this.stats.framesRendered / totalTime : 0;
}

return status;
}

/**
* 获取调试信息
* @returns {string} 格式化的调试信息
*/
getDebugInfo() {
const status = this.getStatus();

let debugInfo = `🎮 === GameBoy 系统状态 ===
系统状态:${status.state}
调度模式:${status.scheduleMode}
运行时间:${status.statistics.totalRunTime?.toFixed(2) || 0}
已渲染帧数:${status.statistics.framesRendered}
执行周期数:${status.statistics.cyclesExecuted}
当前 FPS:${status.statistics.currentFPS?.toFixed(2) || 0}
平均 FPS:${status.statistics.averageFPS?.toFixed(2) || 0}
错误计数:${status.statistics.errors}

📱 硬件组件状态:`;

// CPU 状态
if (this.cpu) {
debugInfo += `\n\n💻 CPU 状态:\n${this.cpu.getStatusString()}`;
}

// GPU 状态
if (this.gpu) {
debugInfo += `\n\n🎨 GPU 状态:\n${this.gpu.getDebugInfo()}`;
}

// MMU 状态
if (this.mmu) {
const mmuStats = this.mmu.getMemoryStats();
debugInfo += `\n\n💾 MMU 状态:
BIOS 启用:${mmuStats.biosEnabled ? '是' : '否'}
ROM 已加载:${mmuStats.romLoaded ? '是' : '否'}
硬件连接:
GPU: ${mmuStats.hardwareConnections.gpu ? '✅' : '❌'}
输入: ${mmuStats.hardwareConnections.input ? '✅' : '❌'}
定时器: ${mmuStats.hardwareConnections.timer ? '✅' : '❌'}
中断: ${mmuStats.hardwareConnections.interrupt ? '✅' : '❌'}`;
}

return debugInfo;
}

/**
* 获取内存转储
* @param {number} address - 起始地址
* @param {number} length - 长度
* @returns {string} 内存转储
*/
getMemoryDump(address, length = 256) {
if (!this.mmu) {
return '❌ MMU 未初始化';
}

return this.mmu.getMemoryDump(address, length);
}

/**
* 设置调试模式
* @param {boolean} enabled - 是否启用调试模式
*/
setDebugMode(enabled) {
window.DEBUG_MODE = enabled;
console.log(`🔧 调试模式${enabled ? '已启用' : '已禁用'}`);
}

/**
* 销毁系统(清理资源)
*/
destroy() {
console.log('💥 销毁 GameBoy 系统...');

// 停止运行
this.stop();

// 清理组件引用
this.cpu = null;
this.mmu = null;
this.gpu = null;
this.graphics = null;

// 清理全局引用
if (window.MMU) delete window.MMU;
if (window.GPU) delete window.GPU;
if (window.CPU) delete window.CPU;
if (window.Graphics) delete window.Graphics;

console.log('✅ 系统已销毁');
}

5.7. 现代化用户界面

展开以查看
index.htmlhtml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GameBoy 模拟器 - JavaScript 版本</title>
<style>
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
font-family: 'Courier New', monospace;
background: linear-gradient(135deg, #2c5530, #4a7c59);
color: #000;
min-height: 100vh;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
}

/* 主容器 */
.gameboy-container {
background: linear-gradient(145deg, #8b956d, #9bb583);
border-radius: 20px 20px 60px 20px;
padding: 40px;
box-shadow:
0 20px 40px rgba(0, 0, 0, 0.3),
inset 0 2px 4px rgba(255, 255, 255, 0.1);
max-width: 700px;
width: 100%;
}

/* 顶部标题区域 */
.header {
text-align: center;
margin-bottom: 30px;
}

.title {
font-size: 28px;
font-weight: bold;
color: #2c5530;
text-shadow:
1px 1px 0 rgba(255, 255, 255, 0.3),
2px 2px 4px rgba(0, 0, 0, 0.2);
margin-bottom: 10px;
letter-spacing: 2px;
}

.subtitle {
font-size: 14px;
color: #5a6b4d;
margin-bottom: 5px;
}

.version {
font-size: 12px;
color: #6b7a5e;
font-style: italic;
}

/* 屏幕区域 */
.screen-container {
background: linear-gradient(145deg, #4a5c3a, #5a6c4a);
border-radius: 15px;
padding: 25px;
margin: 30px auto;
box-shadow:
inset 0 4px 8px rgba(0, 0, 0, 0.3),
0 2px 4px rgba(255, 255, 255, 0.1);
position: relative;
width: fit-content;
}

.screen-bezel {
background: linear-gradient(145deg, #2c3624, #3c4634);
border-radius: 10px;
padding: 15px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.5);
}

/* GameBoy 屏幕 Canvas */
#screen {
display: block;
width: 320px;
height: 288px;
background: #9bbc0f;
border-radius: 5px;
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
box-shadow:
inset 0 0 0 2px #8bac0f,
inset 0 0 0 4px #5a6b4d;
}

/* 屏幕标签 */
.screen-label {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
color: #6b7a5e;
letter-spacing: 1px;
}

/* 系统状态指示器 */
.status-indicator {
position: absolute;
top: 15px;
right: 15px;
display: flex;
gap: 8px;
font-size: 10px;
}

.indicator {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid #333;
}

.indicator.running { background: #00ff00; }
.indicator.stopped { background: #ff0000; }
.indicator.paused { background: #ffff00; }
.indicator.error { background: #ff8800; }

/* 控制面板 */
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 30px;
}

.control-section {
background: linear-gradient(145deg, #7a8a6d, #8a9a7d);
border-radius: 12px;
padding: 20px;
box-shadow:
inset 0 2px 4px rgba(255, 255, 255, 0.1),
0 4px 8px rgba(0, 0, 0, 0.2);
}

.control-title {
font-size: 14px;
font-weight: bold;
color: #2c5530;
margin-bottom: 15px;
text-align: center;
border-bottom: 1px solid #5a6b4d;
padding-bottom: 8px;
}

/* 按钮样式 */
.btn {
background: linear-gradient(145deg, #a5b588, #95a578);
border: none;
border-radius: 8px;
padding: 12px 20px;
color: #2c5530;
font-weight: bold;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow:
0 3px 6px rgba(0, 0, 0, 0.2),
inset 0 1px 2px rgba(255, 255, 255, 0.2);
margin: 5px;
width: calc(100% - 10px);
position: relative;
}

.btn:hover {
background: linear-gradient(145deg, #b5c598, #a5b588);
transform: translateY(-1px);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.3),
inset 0 1px 2px rgba(255, 255, 255, 0.3);
}

.btn:active {
transform: translateY(1px);
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 2px 4px rgba(0, 0, 0, 0.1);
}

.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}

.btn.primary {
background: linear-gradient(145deg, #5a7c4a, #4a6c3a);
color: #fff;
}

.btn.primary:hover:not(:disabled) {
background: linear-gradient(145deg, #6a8c5a, #5a7c4a);
}

.btn.danger {
background: linear-gradient(145deg, #a56565, #955555);
color: #fff;
}

.btn.danger:hover:not(:disabled) {
background: linear-gradient(145deg, #b57575, #a56565);
}

.btn.success {
background: linear-gradient(145deg, #5a8a5a, #4a7a4a);
color: #fff;
}

.btn.success:hover:not(:disabled) {
background: linear-gradient(145deg, #6a9a6a, #5a8a5a);
}

/* 按钮状态指示 */
.btn.loading::after {
content: '';
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
to { transform: translateY(-50%) rotate(360deg); }
}

/* 状态显示区域 */
.status-panel {
margin-top: 30px;
background: linear-gradient(145deg, #3c4634, #4c5644);
border-radius: 12px;
padding: 20px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.3);
}

.status-title {
color: #9bb583;
font-size: 14px;
font-weight: bold;
margin-bottom: 15px;
text-align: center;
}

.status-content {
background: #1a1a1a;
color: #00ff00;
font-family: 'Courier New', monospace;
font-size: 11px;
padding: 15px;
border-radius: 6px;
height: 250px;
overflow-y: auto;
border: 1px solid #333;
white-space: pre-wrap;
}

/* 性能面板 */
.performance-panel {
margin-top: 20px;
background: linear-gradient(145deg, #2c3624, #3c4634);
border-radius: 12px;
padding: 20px;
box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.3);
}

.performance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 15px;
margin-top: 15px;
}

.performance-item {
text-align: center;
padding: 10px;
background: rgba(0, 0, 0, 0.3);
border-radius: 8px;
}

.performance-value {
display: block;
font-size: 18px;
font-weight: bold;
color: #00ff00;
margin-bottom: 5px;
}

.performance-label {
font-size: 10px;
color: #9bb583;
text-transform: uppercase;
}

/* 响应式设计 */
@media (max-width: 768px) {
.gameboy-container {
padding: 20px;
margin: 10px;
}

#screen {
width: 240px;
height: 216px;
}

.controls {
grid-template-columns: 1fr;
gap: 15px;
}

.title {
font-size: 24px;
}

.performance-grid {
grid-template-columns: repeat(2, 1fr);
}
}

/* 信息提示 */
.info-box {
background: linear-gradient(145deg, #6a7c5a, #7a8c6a);
border-radius: 8px;
padding: 15px;
margin: 20px 0;
border-left: 4px solid #4a6c3a;
color: #2c5530;
font-size: 13px;
line-height: 1.5;
}

.info-box h4 {
margin-bottom: 8px;
color: #1a3520;
}

/* 键盘快捷键显示 */
.keyboard-hint {
font-size: 10px;
color: #6b7a5e;
text-align: center;
margin-top: 10px;
}

.key {
background: #5a6b4d;
color: #fff;
padding: 2px 6px;
border-radius: 3px;
font-weight: bold;
margin: 0 2px;
}
</style>
</head>
<body>
<div class="gameboy-container">
<!-- 顶部标题区域 -->
<div class="header">
<h1 class="title">GAME BOY</h1>
<div class="subtitle">JavaScript 模拟器</div>
<div class="version">版本 0.5.0 - 系统集成</div>
</div>

<!-- 屏幕区域 -->
<div class="screen-container">
<div class="status-indicator">
<div class="indicator stopped" id="system-indicator" title="系统状态"></div>
</div>
<div class="screen-bezel">
<canvas id="screen" width="160" height="144"></canvas>
<div class="screen-label">DOT MATRIX WITH STEREO SOUND</div>
</div>
</div>

<!-- 控制面板 -->
<div class="controls">
<!-- 系统控制 -->
<div class="control-section">
<div class="control-title">🎮 系统控制</div>
<button class="btn primary" id="init-btn">初始化系统</button>
<button class="btn success" id="start-btn" disabled>开始运行</button>
<button class="btn" id="pause-btn" disabled>暂停/恢复</button>
<button class="btn danger" id="stop-btn" disabled>停止运行</button>
<button class="btn" id="reset-btn" disabled>重置系统</button>
<div class="keyboard-hint">
快捷键:<span class="key">Space</span> 开始/暂停 <span class="key">R</span> 重置
</div>
</div>

<!-- 程序加载 -->
<div class="control-section">
<div class="control-title">📦 程序加载</div>
<button class="btn" id="load-demo-btn" disabled>加载演示程序</button>
<button class="btn" id="load-rom-btn" disabled>加载 ROM 文件</button>
<input type="file" id="rom-file-input" accept=".gb,.gbc" style="display: none;">
<button class="btn" id="step-btn" disabled>单步执行</button>
<div class="keyboard-hint">
快捷键:<span class="key">L</span> 加载演示 <span class="key">S</span> 单步
</div>
</div>
</div>

<!-- 调试和图形控制 -->
<div class="controls">
<!-- 调试工具 -->
<div class="control-section">
<div class="control-title">🔧 调试工具</div>
<button class="btn" id="show-status-btn">显示系统状态</button>
<button class="btn" id="show-memory-btn">内存转储</button>
<button class="btn" id="show-registers-btn">CPU 寄存器</button>
<button class="btn" id="toggle-debug-btn">切换调试模式</button>
<div class="keyboard-hint">
快捷键:<span class="key">D</span> 调试 <span class="key">M</span> 内存
</div>
</div>

<!-- 图形控制 -->
<div class="control-section">
<div class="control-title">🎨 图形控制</div>
<button class="btn" id="toggle-render-btn">切换渲染模式</button>
<button class="btn" id="show-graphics-btn">图形系统状态</button>
<button class="btn" id="test-graphics-btn">测试图形渲染</button>
<button class="btn danger" id="clear-console-btn">清空控制台</button>
<div class="keyboard-hint">
快捷键:<span class="key">G</span> 图形模式 <span class="key">T</span> 测试
</div>
</div>
</div>

<!-- 性能监控面板 -->
<div class="performance-panel">
<div class="status-title">📊 实时性能监控</div>
<div class="performance-grid">
<div class="performance-item">
<span class="performance-value" id="fps-value">0.0</span>
<span class="performance-label">FPS</span>
</div>
<div class="performance-item">
<span class="performance-value" id="frames-value">0</span>
<span class="performance-label">帧数</span>
</div>
<div class="performance-item">
<span class="performance-value" id="instructions-value">0</span>
<span class="performance-label">指令数</span>
</div>
<div class="performance-item">
<span class="performance-value" id="cycles-value">0</span>
<span class="performance-label">周期数</span>
</div>
<div class="performance-item">
<span class="performance-value" id="errors-value">0</span>
<span class="performance-label">错误数</span>
</div>
<div class="performance-item">
<span class="performance-value" id="uptime-value">0s</span>
<span class="performance-label">运行时间</span>
</div>
</div>
</div>

<!-- 状态显示面板 -->
<div class="status-panel">
<div class="status-title">📋 系统控制台 & 调试输出</div>
<div id="console-output" class="status-content">GameBoy 模拟器 v0.5.0 已准备就绪...
🎮 第五章:系统集成
✨ 新功能:完整的系统架构、CPU 指令集、寄存器控制

等待系统初始化...

使用说明:
1. 点击"初始化系统"启动模拟器
2. 点击"加载演示程序"或加载 ROM 文件
3. 点击"开始运行"启动系统
4. 观察实时性能监控和调试输出
5. 使用调试工具查看系统状态

组件状态:
- CPU: 等待初始化...
- MMU: 等待初始化...
- GPU: 等待初始化...
- 图形系统: 等待初始化...
- 系统调度器: 等待初始化...
</div>
</div>
</div>

<!-- JavaScript 文件导入 -->
<script src="mmu.js"></script>
<script src="graphics.js"></script>
<script src="gpu.js"></script>
<script src="cpu.js"></script>
<script src="gameboy.js"></script>

<!-- 主程序脚本 -->
<script>
(function() {
'use strict';

// 全局变量
let systemInstance = null; // 避免与 gameboy.js 中的变量冲突
let performanceUpdateInterval = null;
let isDebugMode = false;

// UI 元素引用
const elements = {
// 按钮
initBtn: document.getElementById('init-btn'),
startBtn: document.getElementById('start-btn'),
pauseBtn: document.getElementById('pause-btn'),
stopBtn: document.getElementById('stop-btn'),
resetBtn: document.getElementById('reset-btn'),
loadDemoBtn: document.getElementById('load-demo-btn'),
loadRomBtn: document.getElementById('load-rom-btn'),
stepBtn: document.getElementById('step-btn'),
showStatusBtn: document.getElementById('show-status-btn'),
showMemoryBtn: document.getElementById('show-memory-btn'),
showRegistersBtn: document.getElementById('show-registers-btn'),
toggleDebugBtn: document.getElementById('toggle-debug-btn'),
toggleRenderBtn: document.getElementById('toggle-render-btn'),
showGraphicsBtn: document.getElementById('show-graphics-btn'),
testGraphicsBtn: document.getElementById('test-graphics-btn'),
clearConsoleBtn: document.getElementById('clear-console-btn'),

// 状态指示器
systemIndicator: document.getElementById('system-indicator'),

// 性能显示
fpsValue: document.getElementById('fps-value'),
framesValue: document.getElementById('frames-value'),
instructionsValue: document.getElementById('instructions-value'),
cyclesValue: document.getElementById('cycles-value'),
errorsValue: document.getElementById('errors-value'),
uptimeValue: document.getElementById('uptime-value'),

// 控制台输出
consoleOutput: document.getElementById('console-output'),

// 文件输入
romFileInput: document.getElementById('rom-file-input')
};

// 添加日志输出功能
function addLog(message, type = 'info') {
const timestamp = new Date().toLocaleTimeString();
const prefix = {
'info': '📝',
'success': '✅',
'warning': '⚠️',
'error': '❌',
'debug': '🔧'
}[type] || '📝';

elements.consoleOutput.textContent += `[${timestamp}] ${prefix} ${message}\n`;
elements.consoleOutput.scrollTop = elements.consoleOutput.scrollHeight;
}

// 重写 console.log 以在页面上显示
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;

console.log = function(...args) {
originalConsoleLog.apply(console, args);
const message = args.join(' ');

// 只有重要消息才显示到页面
const importantKeywords = ['✅', '❌', '⚠️', '🎮', '📊', '🔧', '初始化', '加载', '错误', '完成'];
if (importantKeywords.some(keyword => message.includes(keyword)) || isDebugMode) {
addLog(message);
}
};

console.error = function(...args) {
originalConsoleError.apply(console, args);
addLog(args.join(' '), 'error');
};

console.warn = function(...args) {
originalConsoleWarn.apply(console, args);
addLog(args.join(' '), 'warning');
};

// 更新系统状态指示器
function updateSystemIndicator(state) {
const indicator = elements.systemIndicator;
indicator.className = `indicator ${state}`;
indicator.title = `系统状态: ${state}`;
}

// 更新按钮状态
function updateButtonStates(systemState) {
const isInitialized = systemInstance !== null;
const isRunning = systemState === 'running';
const isStopped = systemState === 'stopped';

elements.initBtn.disabled = isInitialized;
elements.startBtn.disabled = !isInitialized || isRunning;
elements.pauseBtn.disabled = !isInitialized || isStopped;
elements.stopBtn.disabled = !isInitialized || isStopped;
elements.resetBtn.disabled = !isInitialized;
elements.loadDemoBtn.disabled = !isInitialized;
elements.loadRomBtn.disabled = !isInitialized;
elements.stepBtn.disabled = !isInitialized || isRunning;
}

// 系统初始化
async function initializeSystem() {
try {
addLog('正在初始化 GameBoy 系统...', 'info');
elements.initBtn.classList.add('loading');

// 使用系统控制器
systemInstance = await window.GameBoySystemController.init();

updateSystemIndicator('stopped');
updateButtonStates('stopped');

// 启动性能监控
startPerformanceMonitoring();

addLog('✅ GameBoy 系统初始化完成', 'success');

} catch (error) {
addLog(`❌ 系统初始化失败: ${error.message}`, 'error');
updateSystemIndicator('error');
} finally {
elements.initBtn.classList.remove('loading');
}
}

// 系统控制函数
function startSystem() {
if (!systemInstance) {
addLog('❌ 系统未初始化', 'error');
return;
}

try {
window.GameBoySystemController.start();
updateSystemIndicator('running');
updateButtonStates('running');
addLog('🚀 系统开始运行', 'success');
} catch (error) {
addLog(`❌ 启动失败: ${error.message}`, 'error');
}
}

function pauseSystem() {
if (!systemInstance) return;

try {
window.GameBoySystemController.togglePause();
const status = window.GameBoySystemController.getStatus();
updateSystemIndicator(status.state);
updateButtonStates(status.state);
addLog(`⏸️ 系统${status.state === 'paused' ? '已暂停' : '继续运行'}`, 'info');
} catch (error) {
addLog(`❌ 暂停操作失败: ${error.message}`, 'error');
}
}

function stopSystem() {
if (!systemInstance) return;

try {
window.GameBoySystemController.stop();
updateSystemIndicator('stopped');
updateButtonStates('stopped');
addLog('⏹️ 系统已停止', 'info');
} catch (error) {
addLog(`❌ 停止操作失败: ${error.message}`, 'error');
}
}

function resetSystem() {
if (!systemInstance) return;

try {
window.GameBoySystemController.reset();
updateSystemIndicator('stopped');
updateButtonStates('stopped');
addLog('🔄 系统已重置', 'info');
} catch (error) {
addLog(`❌ 重置失败: ${error.message}`, 'error');
}
}

function stepSystem() {
if (!systemInstance) return;

try {
// TODO: 实现单步执行
addLog('👣 执行单步操作...', 'debug');
} catch (error) {
addLog(`❌ 单步执行失败: ${error.message}`, 'error');
}
}

// 程序加载函数
async function loadDemo() {
if (!systemInstance) {
addLog('❌ 系统未初始化', 'error');
return;
}

try {
addLog('📦 正在加载演示程序...', 'info');
const success = window.GameBoySystemController.loadDemo();

if (success) {
addLog('✅ 演示程序加载完成', 'success');
addLog('💡 现在可以点击"开始运行"启动系统', 'info');
} else {
addLog('❌ 演示程序加载失败', 'error');
}
} catch (error) {
addLog(`❌ 加载演示程序失败: ${error.message}`, 'error');
}
}

function loadROMFile() {
elements.romFileInput.click();
}

async function handleROMFile(event) {
const file = event.target.files[0];
if (!file) return;

try {
addLog(`📦 正在加载 ROM 文件: ${file.name}`, 'info');

const arrayBuffer = await file.arrayBuffer();
const success = await window.GameBoySystemController.loadROM(arrayBuffer);

if (success) {
addLog(`✅ ROM 文件加载完成: ${file.name}`, 'success');
} else {
addLog(`❌ ROM 文件加载失败: ${file.name}`, 'error');
}
} catch (error) {
addLog(`❌ 文件读取失败: ${error.message}`, 'error');
}
}

// 调试工具函数
function showSystemStatus() {
if (!systemInstance) {
addLog('❌ 系统未初始化', 'error');
return;
}

try {
const debugInfo = window.GameBoySystemController.getDebugInfo();
addLog('📊 === 系统状态 ===', 'debug');
addLog(debugInfo, 'debug');
addLog('=================', 'debug');
} catch (error) {
addLog(`❌ 获取系统状态失败: ${error.message}`, 'error');
}
}

function showMemoryDump() {
if (!systemInstance) {
addLog('❌ 系统未初始化', 'error');
return;
}

try {
const system = window.GameBoySystemController.getInstance();
if (system && system.mmu) {
addLog('💾 === 内存转储 (0x0000-0x00FF) ===', 'debug');
const dump = system.getMemoryDump(0x0000, 256);
addLog(dump, 'debug');
}
} catch (error) {
addLog(`❌ 内存转储失败: ${error.message}`, 'error');
}
}

function showCPURegisters() {
if (!systemInstance) {
addLog('❌ 系统未初始化', 'error');
return;
}

try {
const system = window.GameBoySystemController.getInstance();
if (system && system.cpu) {
addLog('💻 === CPU 寄存器状态 ===', 'debug');
addLog(system.cpu.getStatusString(), 'debug');
}
} catch (error) {
addLog(`❌ 获取 CPU 状态失败: ${error.message}`, 'error');
}
}

function toggleDebugMode() {
isDebugMode = !isDebugMode;
window.DEBUG_MODE = isDebugMode;
addLog(`🔧 调试模式${isDebugMode ? '已启用' : '已禁用'}`, 'debug');
}

// 图形控制函数
function toggleRenderMode() {
try {
const system = window.GameBoySystemController.getInstance();
if (system && system.gpu) {
const currentMode = system.gpu.renderMode || 'test';
const newMode = currentMode === 'test' ? 'graphics' : 'test';
system.gpu.setRenderMode(newMode);
addLog(`🎨 渲染模式已切换到:${newMode === 'test' ? '测试模式' : '图形模式'}`, 'info');
}
} catch (error) {
addLog(`❌ 切换渲染模式失败: ${error.message}`, 'error');
}
}

function showGraphicsStatus() {
try {
const system = window.GameBoySystemController.getInstance();
if (system && system.gpu) {
addLog('🎨 === 图形系统状态 ===', 'debug');
addLog(system.gpu.getDebugInfo(), 'debug');
}
} catch (error) {
addLog(`❌ 获取图形状态失败: ${error.message}`, 'error');
}
}

function testGraphicsSystem() {
try {
const system = window.GameBoySystemController.getInstance();
if (system && system.gpu) {
addLog('🧪 运行图形系统测试...', 'debug');

// 创建测试数据
if (system.mmu && system.mmu.vram) {
const vram = system.mmu.vram;

// 创建测试瓦片
for (let i = 0; i < 16; i += 2) {
vram[0x8000 + i] = Math.floor(Math.random() * 256);
vram[0x8000 + i + 1] = Math.floor(Math.random() * 256);
}

addLog('✅ 测试数据已生成', 'debug');
}

// 测试渲染
system.gpu.stepFrame();
addLog('✅ 图形系统测试完成', 'success');
}
} catch (error) {
addLog(`❌ 图形系统测试失败: ${error.message}`, 'error');
}
}

function clearConsoleLog() {
elements.consoleOutput.textContent = 'GameBoy 模拟器控制台\n已清空...\n\n';
}

// 性能监控
function startPerformanceMonitoring() {
performanceUpdateInterval = setInterval(updatePerformanceDisplay, 500);
}

function updatePerformanceDisplay() {
if (!systemInstance) return;

try {
const status = window.GameBoySystemController.getStatus();
if (!status) return;

const stats = status.statistics;

// 更新显示
elements.fpsValue.textContent = (stats.currentFPS || 0).toFixed(1);
elements.framesValue.textContent = stats.framesRendered || 0;
elements.instructionsValue.textContent = formatNumber(stats.cyclesExecuted / 4 || 0);
elements.cyclesValue.textContent = formatNumber(stats.cyclesExecuted || 0);
elements.errorsValue.textContent = stats.errors || 0;
elements.uptimeValue.textContent = formatTime(stats.totalRunTime || 0);

} catch (error) {
// 静默处理性能监控错误
}
}

function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
}

function formatTime(seconds) {
if (seconds >= 60) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes}m${remainingSeconds}s`;
}
return `${Math.floor(seconds)}s`;
}

// 绑定事件监听器
function bindEventListeners() {
elements.initBtn.addEventListener('click', initializeSystem);
elements.startBtn.addEventListener('click', startSystem);
elements.pauseBtn.addEventListener('click', pauseSystem);
elements.stopBtn.addEventListener('click', stopSystem);
elements.resetBtn.addEventListener('click', resetSystem);
elements.loadDemoBtn.addEventListener('click', loadDemo);
elements.loadRomBtn.addEventListener('click', loadROMFile);
elements.stepBtn.addEventListener('click', stepSystem);
elements.showStatusBtn.addEventListener('click', showSystemStatus);
elements.showMemoryBtn.addEventListener('click', showMemoryDump);
elements.showRegistersBtn.addEventListener('click', showCPURegisters);
elements.toggleDebugBtn.addEventListener('click', toggleDebugMode);
elements.toggleRenderBtn.addEventListener('click', toggleRenderMode);
elements.showGraphicsBtn.addEventListener('click', showGraphicsStatus);
elements.testGraphicsBtn.addEventListener('click', testGraphicsSystem);
elements.clearConsoleBtn.addEventListener('click', clearConsoleLog);
elements.romFileInput.addEventListener('change', handleROMFile);
}

// 键盘快捷键
document.addEventListener('keydown', function(event) {
// 防止在输入框中触发快捷键
if (event.target.tagName === 'INPUT') return;

switch(event.code) {
case 'Space':
event.preventDefault();
if (systemInstance) {
const status = window.GameBoySystemController.getStatus();
if (status.state === 'running') {
pauseSystem();
} else {
startSystem();
}
}
break;
case 'KeyR':
event.preventDefault();
resetSystem();
break;
case 'KeyL':
event.preventDefault();
loadDemo();
break;
case 'KeyS':
event.preventDefault();
stepSystem();
break;
case 'KeyD':
event.preventDefault();
showSystemStatus();
break;
case 'KeyM':
event.preventDefault();
showMemoryDump();
break;
case 'KeyG':
event.preventDefault();
toggleRenderMode();
break;
case 'KeyT':
event.preventDefault();
testGraphicsSystem();
break;
}
});

// 页面加载完成后的初始化
window.addEventListener('load', function() {
bindEventListeners();

addLog('🎮 GameBoy 模拟器 v0.5.0 已准备就绪', 'success');
addLog('📚 当前版本:第五章 - 系统集成', 'info');
addLog('✨ 新功能:完整系统架构、CPU 指令集、寄存器控制', 'info');
addLog('💡 点击"初始化系统"开始使用', 'info');

// 检查组件加载状态
setTimeout(() => {
const components = [
{ name: 'MMU', check: () => typeof GameBoyMMU !== 'undefined' },
{ name: 'GPU', check: () => typeof GameBoyGPU !== 'undefined' },
{ name: 'CPU', check: () => typeof GameBoyCPU !== 'undefined' },
{ name: '图形系统', check: () => typeof GameBoyGraphicsSystem !== 'undefined' },
{ name: '系统控制器', check: () => typeof GameBoySystem !== 'undefined' }
];

components.forEach(comp => {
if (comp.check()) {
addLog(`✅ ${comp.name} 组件已加载`, 'success');
} else {
addLog(`❌ ${comp.name} 组件加载失败`, 'error');
}
});

addLog('🔧 所有组件检查完成,可以开始初始化', 'info');
}, 500);
});

// 页面卸载时清理
window.addEventListener('beforeunload', function() {
if (performanceUpdateInterval) {
clearInterval(performanceUpdateInterval);
}

if (systemInstance) {
window.GameBoySystemController.stop();
}
});

})();
</script>
</body>
</html>

效果(点击 初始化系统切换渲染模式测试图形渲染加载演示程序开始运行 ):

alt text

5.8. 全局系统控制接口

为了方便外部访问和调试,我们写一个统一的全局的系统控制接口:

gameboy.jsjavascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
/**
* 全局系统控制函数
*/
window.GameBoySystemController = {
/**
* 初始化系统
*/
async init() {
try {
if (!gameBoySystem) {
gameBoySystem = new GameBoySystem();
}

await gameBoySystem.initialize();
window.GameBoySystem = gameBoySystem; // 全局引用
return gameBoySystem;
} catch (error) {
console.error('❌ 系统初始化失败:', error);
throw error;
}
},

/**
* 获取系统实例
*/
getInstance() {
return gameBoySystem;
},

/**
* 重置系统
*/
reset() {
if (gameBoySystem) {
gameBoySystem.reset();
}
},

/**
* 启动系统
*/
start(mode) {
if (gameBoySystem) {
gameBoySystem.start(mode);
}
},

/**
* 停止系统
*/
stop() {
if (gameBoySystem) {
gameBoySystem.stop();
}
},

/**
* 暂停/恢复
*/
togglePause() {
if (gameBoySystem) {
gameBoySystem.togglePause();
}
},

/**
* 加载 ROM
*/
async loadROM(romData) {
if (gameBoySystem) {
return await gameBoySystem.loadROM(romData);
}
return false;
},

/**
* 加载演示
*/
loadDemo() {
if (gameBoySystem) {
return gameBoySystem.loadDemo();
}
return false;
},

/**
* 获取状态
*/
getStatus() {
if (gameBoySystem) {
return gameBoySystem.getStatus();
}
return null;
},

/**
* 获取调试信息
*/
getDebugInfo() {
if (gameBoySystem) {
return gameBoySystem.getDebugInfo();
}
return '系统未初始化';
}
};

这个控制器接口使得前端可以通过简洁的 API 来操作整个模拟器系统,同时在浏览器控制台中也可以直接调用这些方法进行调试:

javascript
1
2
3
4
5
6
7
8
9
// 在浏览器控制台中
const system = window.GameBoySystemController.getInstance();

// 查看系统状态
console.log(system.getStatus());

// 测试寄存器
system.gpu.writeRegister(0xFF43, 50); // 设置水平滚动
console.log(system.gpu.readRegister(0xFF43)); // 读取确认

alt text