OHOS NES Game —— 移植infoNES模拟器到鸿蒙PC平台详解
1. 项目概述
OHOS NES Game 是一个基于 InfoNES 开源模拟器核心,移植到 HarmonyOS(OpenHarmony)平台的任天堂红白机(NES / FC)模拟器。项目使用 ArkTS 构建用户界面,C++ 编写模拟器核心,通过 NAPI 桥接两层,最终利用 XComponent + native_window 实现 60 FPS 的视频渲染。
项目开源地址:https://gitcode.com/qq8864/ohos_nes_game
infoNES 是一个功能强大的NES游戏模拟器,它为玩家在现代计算机上重温经典NES游戏提供了可能。然而,当我们将 NES 游戏画面放大到大屏幕时,原始设计中的最近邻插值算法导致画面颗粒感严重,细节模糊。本文将探讨如何优化 infoNES 的显示效果,使其在大屏幕上也能保持清晰、自然的画面。
博主之前的那篇嵌入式linux下NES游戏模拟器移植文章地址:
iMX6ULL应用移植 | 移植 infoNES 模拟器(重玩经典NES游戏)

1.1 技术栈
| 层级 | 技术 |
|---|---|
| UI 框架 | ArkTS (Stage Model) |
| 原生层 | C++ (InfoNES + K6502) |
| 桥接 | NAPI (napi_init.cpp) |
| 渲染 | XComponent + OHNativeWindow |
| 构建 | CMake + hvigor |
| 目标设备 | Phone / Tablet / 2-in-1 (API 12) |
1.2 功能特性
- NES 模拟核心:6502 CPU、PPU 图形、APU 音频(框架已集成)
- ROM 加载:系统文件选择器 + 内置 Demo ROM
- 触控手柄:方向键 + AB + Select/Start
- ~60 FPS 渲染:独立模拟线程 + Mutex 保护帧缓冲
- 100+ Mapper 支持:MMC1、MMC3、UNROM、CNROM、NROM 等
2. 整体架构
┌─────────────────────────────────────────────────┐
│ Index.ets (ArkTS) │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ FilePicker│ │ Gamepad │ │ XComponent │ │
│ │ 选择 ROM │ │ 触控手柄 │ │ (Surface) │ │
│ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │
│ │ │ │ │
└───────┼────────────┼────────────────┼────────────┘
│ NAPI call │ NAPI call │ OHNativeWindow
▼ ▼ ▼
┌─────────────────────────────────────────────────┐
│ libentry.so (C++ Native) │
│ ┌─────────────────────────────────────────────┐ │
│ │ napi_init.cpp (NAPI Bridge) │ │
│ │ ┌─────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ ROM加载 │ │ 帧渲染 │ │ Surface管理 │ │ │
│ │ └────┬────┘ └────┬─────┘ └──────────────┘ │ │
│ └───────┼───────────┼─────────────────────────┘ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ InfoNES Core (C++) │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ K6502 │ │ PPU │ │ Mapper(100+) │ │ │
│ │ │ CPU模拟 │ │ 图形渲染 │ │ 银行切换 │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ pAPU (音频处理单元) │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
3. 模拟器核心移植
3.1 InfoNES 简介
InfoNES 是一个用 C 语言编写的开源 NES 模拟器,最初于 2000 年发布。其核心组件包括:
- K6502 — 6502 CPU 模拟器,支持所有官方指令和中断(NMI/IRQ)
- InfoNES — 主循环 + PPU 图形渲染 + 时序控制
- InfoNES_Mapper — 卡带 Mapper 框架,支持 100+ 种 Mapper
- InfoNES_pAPU — 音频处理单元
3.2 平台适配层
InfoNES 原版通过一组回调函数实现平台无关性。我们实现了 InfoNES_System_HarmonyOS.cpp,提供鸿蒙平台的具体实现:
| 回调函数 | 功能 | 实现方式 |
|---|---|---|
InfoNES_LoadFrame() |
每帧完成时回调,拷贝帧缓冲 | Mutex 保护下拷贝 WorkFrame 到 g_frameBuffer |
InfoNES_PadState() |
读取手柄状态 | 从 NAPI 传入的 g_dwKeyPad1/2 读取 |
InfoNES_Wait() |
帧同步等待 | chrono::steady_clock 实现 64μs 精确定时 |
InfoNES_MessageBox() |
消息弹窗 | hilog 输出 |
InfoNES_MemoryCopy/Set() |
内存操作 | memcpy/memset 封装 |
3.3 ROM 加载
// InfoNES_LoadFromBuffer:从内存加载 ROM 文件
int InfoNES_LoadFromBuffer(const uint8_t *data, size_t size)
{
memcpy(&NesHeader, data, sizeof(NesHeader));
if (memcmp(NesHeader.byID, "NES\x1a", 4) != 0) return -1;
// 跳过 Trainer(如果存在)
if (NesHeader.byInfo1 & 4) { ... }
// 分配 PRG ROM
DWORD romSize = NesHeader.byRomSize ? (NesHeader.byRomSize * 0x4000) : 0x40000;
ROM = (BYTE *)malloc(romSize);
memcpy(ROM, data + offset, copySize);
// 分配 CHR ROM(如果存在)
if (NesHeader.byVRomSize > 0) {
VROM = (BYTE *)malloc(NesHeader.byVRomSize * 0x2000);
memcpy(VROM, data + offset, NesHeader.byVRomSize * 0x2000);
}
}
4. NAPI 桥接层
4.1 导出接口
通过 napi_define_properties 向 ArkTS 层暴露以下接口:
| 接口名 | 用途 | 参数 | 返回值 |
|---|---|---|---|
startRom |
启动模拟(加载 ROM + 启动线程) | ArrayBuffer (ROM 数据) | boolean |
stopRom |
停止模拟 | 无 | void |
renderFrame |
渲染一帧到 Surface | 无 | void |
setPadState |
设置手柄状态 | pad1, pad2, system | void |
isRunning |
检查模拟是否运行中 | 无 | boolean |
isFrameReady |
检查是否有新帧可用 | 无 | boolean |
4.2 线程模型
模拟器在一个独立的 std::thread 中运行,主线程负责 UI 和渲染:
ArkTS 主线程 (setInterval 16ms)
│
├── nesEmu.renderFrame() ← 每 16ms 调用
│ └── RenderFrameToSurface()
│ ├── RequestBuffer
│ ├── mmap → 写入像素
│ └── FlushBuffer
│
C++ Emu Thread (分离线程)
│
└── InfoNES_Cycle()
├── K6502_Step(113) ← 每个扫描行执行 ~113 CPU 周期
├── MapperHSync()
├── InfoNES_HSync()
│ ├── InfoNES_HSyncNotify() ← 我们的钩子
│ ├── InfoNES_DrawLine() ← PPU 渲染扫描行
│ └── case SCAN_VBLANK_START → InfoNES_LoadFrame()
└── InfoNES_Wait() ← 同步等待
4.3 线程安全
帧缓冲通过 std::mutex g_frameMutex 保护:
// 模拟线程写入
void InfoNES_LoadFrame() {
std::lock_guard<std::mutex> lock(g_frameMutex);
memcpy(g_frameBuffer, WorkFrame, ...);
g_needsRender.store(true);
}
// 渲染线程读取
void RenderFrameToSurface() {
std::lock_guard<std::mutex> lock(g_frameMutex);
// 从 g_frameBuffer 读取像素数据
}
5. 渲染管线
5.1 数据流
InfoNES PPU → WorkFrame[] (RGB565)
↓ mutex lock
g_frameBuffer[] (中间缓冲)
↓ mmap
OHNativeWindowBuffer (共享内存)
↓ RGB565 → RGBA8888 转换
OH_NativeWindow_NativeWindowFlushBuffer → 屏幕显示
5.2 Surface 初始化
static void OnSurfaceCreatedCB(OH_NativeXComponent *component, void *window)
{
g_nativeWindow = (OHNativeWindow *)window;
// 设置缓冲区尺寸(NES 分辨率 256×240)
OH_NativeWindow_NativeWindowHandleOpt(g_nativeWindow, SET_BUFFER_GEOMETRY, 256, 240);
// 设置像素格式为 RGBA8888
OH_NativeWindow_NativeWindowHandleOpt(g_nativeWindow, SET_FORMAT, NATIVEBUFFER_PIXEL_FMT_RGBA_8888);
// 设置内存用途(CPU 写入 + GPU 渲染)
uint64_t usage = NATIVEBUFFER_USAGE_CPU_WRITE | NATIVEBUFFER_USAGE_HW_RENDER;
OH_NativeWindow_NativeWindowHandleOpt(g_nativeWindow, SET_USAGE, usage);
}
5.3 RGB565 → RGBA8888 转换
InfoNES 内部使用 RGB565 格式(16位),鸿蒙 Surface 需要 RGBA8888(32位)。转换逻辑:
WORD rgb565 = raw & 0x7FFF; // 剥离内部标志位
uint8_t r5 = (rgb565 >> 11) & 0x1F;
uint8_t g6 = (rgb565 >> 5) & 0x3F;
uint8_t b5 = rgb565 & 0x1F;
// 5位 → 8位扩展
r5 = (r5 << 3) | (r5 >> 2);
g6 = (g6 << 2) | (g6 >> 4);
b5 = (b5 << 3) | (b5 >> 2);
uint32_t pixel = 0xFF000000 | (b5 << 16) | (g6 << 8) | r5;
5.4 居中缩放
考虑到实际设备分辨率可能远大于 NES 的 256×240,实现了居中缩放:
float scaleX = (float)surfaceWidth / NES_DISP_WIDTH; // 256
float scaleY = (float)surfaceHeight / NES_DISP_HEIGHT; // 240
float scale = min(scaleX, scaleY);
int32_t dstW = (int32_t)(NES_DISP_WIDTH * scale);
int32_t dstH = (int32_t)(NES_DISP_HEIGHT * scale);
int32_t offX = (surfaceWidth - dstW) / 2;
int32_t offY = (surfaceHeight - dstH) / 2;
6. PPU 寄存器强制管理
6.1 背景:为什么需要强制
InfoNES 原版中,PPU 寄存器 $2000 (PPU_R0) 和 $2001 (PPU_R1) 完全由游戏代码控制。但在移植过程中发现,许多测试 ROM 和部分商业游戏的初始化代码不会正确设置这两个寄存器,导致屏幕始终关闭(黑屏)。
为此引入了 InfoNES_HSyncNotify() 钩子,在每个扫描行渲染前强制设置关键位:
void InfoNES_HSyncNotify()
{
PPU_R1 |= 0x1E; // 开启屏幕显示(BG + Sprite)
PPU_R0 |= 0x80; // 开启 NMI(垂直消隐中断)
}
6.2 强制策略
PPU_R0 |= 0x80:确保 NMI 中断持续触发(游戏可能清除 bit 7)PPU_R1 |= 0x1E:确保屏幕持续渲染(游戏可能写入 0x00 关闭屏幕)|=操作为二进制 OR,游戏写入的具体配置位不受影响- 该策略对所有 ROM 一视同仁,不影响商业游戏的正常运行
6.3 VBlank 标志修复
InfoNES 原版在读取 $2002(PPU 状态)时,通过 #if 0 禁用了 VBlank 标志的清除:
// InfoNES 原版(已禁用)
#if 0
PPU_R2 &= ~R2_IN_VBLANK; // 不清除 VBlank 标志
#endif
但许多 NES 游戏依赖读取 $2002 来清除 VBlank 标志。不清除会导致游戏卡在"等待 VBlank 结束"的死循环中。我们将其改为 #if 1:
#if 1
PPU_R2 &= ~R2_IN_VBLANK; // 正确清除 VBlank 标志
#endif
7. CHR-ROM 数据处理
7.1 问题
InfoNES 有一个 InfoNES_SetupChr() 函数,负责将 PPUBANK 中的原始 CHR 数据(2bpp 平面格式)转换为内部线性格式(4bpp,存储在 ChrBuf 中)。原版代码只在 byVRomSize == 0(即使用 CHR-RAM)时调用此函数:
// InfoNES 原版
if (NesHeader.byVRomSize == 0 && FrameCnt == 0)
InfoNES_SetupChr();
对于使用 CHR-ROM 的游戏(绝大多数商业游戏),byVRomSize > 0,SetupChr 从未被调用。结果 ChrBuf 始终保持全零,所有瓦片图案都是空白。
7.2 修复
移除了 byVRomSize 检查,使 SetupChr 对所有 ROM 都执行:
if (FrameCnt == 0)
InfoNES_SetupChr();
这样 CHR-ROM 中的瓦片数据会被正确转换为渲染管线可用的格式。
7.3 CHR 数据格式转换
原始 2bpp 平面格式(每瓦片 16 字节):
位平面 0 (8 bytes): AAAAAAAA BBBBBBBB ...
位平面 1 (8 bytes): CCCCCCCC DDDDDDDD ...
转换后 4bpp 线性格式(每瓦片 64 字节):
像素 0-3: (A0,C0) (A1,C1) (A2,C2) ...
像素 4-7: (B0,D0) (B1,D1) (B2,D2) ...
转换算法:
byData1 = ((pbyBGData[0] >> 1) & 0x55) | (pbyBGData[8] & 0xAA);
byData2 = (pbyBGData[0] & 0x55) | ((pbyBGData[8] << 1) & 0xAA);
8. 调色板管理
8.1 NesPalette 调色板表
NesPalette 是一个硬编码的 64 色调色板查找表,将 NES 的 6 位颜色索引(0-63)映射为 RGB565 值。例如:
WORD NesPalette[64] = {
0x39ce, 0x1071, 0x0015, ... // 64 种 NES 标准颜色
};
8.2 PalTable 运行时调色板
当游戏通过 $2006(PPU 地址)/ $2007(PPU 数据)写入调色板内存($3F00-$3F1F)时,K6502_rw.h 中的写处理器会将 NES 颜色索引转换为 NesPalette 中的 RGB565 值,存入 PalTable[32]:
// $3F00 背景色(带镜像标志)
PalTable[0x00] = NesPalette[byData] | 0x8000;
// 其他调色板条目
PalTable[addr & 0x1f] = NesPalette[byData];
8.3 0x8000 标志位
0x8000 标志位用于区分背景色(像素索引 0 使用 PalTable[0/4/8/0x10/0x14/0x18/0x1c])和其他颜色。这个标志位在渲染时必须被剥离,否则会导致 RGB565 颜色值被污染(R 通道的最高位被置 1)。
WORD rgb565 = raw & 0x7FFF; // 剥离 0x8000 标志位
9. 手柄输入
9.1 触控手柄布局
[▲]
[◄] [►] [Select] [Start]
[▼]
[B] [A]
手柄状态使用位掩码表示:
| 位 | 按钮 | 值 |
|---|---|---|
| 0 | A | 0x01 |
| 1 | B | 0x02 |
| 2 | Select | 0x04 |
| 3 | Start | 0x08 |
| 4 | Up | 0x10 |
| 5 | Down | 0x20 |
| 6 | Left | 0x40 |
| 7 | Right | 0x80 |
9.2 NES 标准读取流程
NES 主机通过 $4016(IO 端口)读取手柄状态,每次读取一位:
// K6502_rw.h: 读取 $4016
if (wAddr == 0x4016) {
byRet = (BYTE)((PAD1_Latch >> PAD1_Bit) & 1) | 0x40;
PAD1_Bit = (PAD1_Bit == 23) ? 0 : (PAD1_Bit + 1);
}
10. 安全关机和 ROM 切换
10.1 问题
当用户点击 Demo 按钮后又点击 Open NES 加载新的 ROM,旧的模拟线程需要被安全终止,否则会出现两个线程同时运行的竞态条件。
10.2 解决方案
使用原子标志 g_emuExiting 协调线程退出:
// 1. 设置退出标志
g_emuExiting.store(true);
g_dwKeySystem |= PAD_SYS_QUIT;
// 2. 等待旧线程检测到退出信号
std::this_thread::sleep_for(std::chrono::milliseconds(20));
// 3. 清除所有状态
g_dwKeyPad1 = 0;
g_dwKeyPad2 = 0;
g_dwKeySystem = 0;
g_emuExiting.store(false);
// 4. 加载新 ROM 并启动新线程
InfoNES_LoadRomFromBuffer(...);
InfoNES_StartEmulation();
模拟线程的 InfoNES_Wait() 会检查 g_emuExiting 标志:
void InfoNES_Wait() {
if (g_emuExiting.load()) {
g_dwKeySystem |= PAD_SYS_QUIT; // 强制退出
}
// ... 帧同步等待
}
11. 调试与诊断
11.1 运行时日志
通过 hilog 输出每个模拟帧的诊断数据:
f=750 nz=61436 r0=A8 r1=1E pt0=8CEB pt1=0000 pt2=0000 pt3=0000 vbc=898 nmi=897
字段含义:
| 字段 | 含义 |
|---|---|
f |
帧号 |
nz |
WorkFrame 中非零像素数(61440 为满屏) |
r0 |
PPU_R0 寄存器值($2000) |
r1 |
PPU_R1 寄存器值($2001) |
pt0-pt3 |
调色板前 4 项原始值 |
vbc |
VBlank 触发次数 |
nmi |
NMI 触发次数 |
11.2 常见问题排查
| 现象 | 可能原因 |
|---|---|
| 黑屏,nz=0 | PPU_R1 屏幕关闭(检查 HSyncNotify) |
| 绿色背景,nz≈61436 | PPU 渲染正常,调色板未写入(游戏初始化为黑色 0x0F) |
| NMI 只触发一次 | PPU_R0 bit 7 被游戏清除(需要 HSyncNotify 持续设置) |
| 仿真线程立即退出 | g_dwKeySystem 残留 QUIT 标志(见第 10 章) |
| 图案显示但颜色错误 | CHR 数据正确,但调色板未正确加载 |
12. 项目文件结构
entry/src/main/cpp/
├── napi_init.cpp # NAPI 桥接 + 渲染 + ROM 加载
├── InfoNES.cpp # 模拟器主循环 + PPU 渲染
├── InfoNES.h # 核心数据结构定义
├── K6502.cpp # 6502 CPU 模拟器
├── K6502.h # CPU 寄存器 + 中断宏
├── K6502_rw.h # CPU 读写操作(PPU/APU/Mapper 路由)
├── InfoNES_System_HarmonyOS.cpp # 鸿蒙平台适配层
├── InfoNES_System.h # 平台适配接口声明
├── InfoNES_Mapper.cpp # Mapper 框架
├── InfoNES_Mapper.h # Mapper 宏 + 函数表
├── InfoNES_pAPU.cpp # 音频处理
├── InfoNES_Shared.h # 跨文件全局变量声明
└── mapper/ # 100+ 个独立 Mapper 实现
13. 后续优化方向
- 音频输出 — 集成 InfoNES_pAPU,通过 OHAudio 输出声音
- 性能优化 — 使用 NEON 指令集加速 RGB565→RGBA8888 转换
- 存档功能 — 支持 SRAM 存档读写(
$6000-$7FFF) - 多语言 UI — 支持中英文界面切换
- 横竖屏适配 — 根据设备姿态调整布局
- 金手指 — 实现 Game Genie / Pro Action Replay 作弊码
- 蓝牙手柄 — 支持外接蓝牙游戏手柄
14. 参考资料
更多推荐


所有评论(0)