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 > 0SetupChr 从未被调用。结果 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. 后续优化方向

  1. 音频输出 — 集成 InfoNES_pAPU,通过 OHAudio 输出声音
  2. 性能优化 — 使用 NEON 指令集加速 RGB565→RGBA8888 转换
  3. 存档功能 — 支持 SRAM 存档读写($6000-$7FFF
  4. 多语言 UI — 支持中英文界面切换
  5. 横竖屏适配 — 根据设备姿态调整布局
  6. 金手指 — 实现 Game Genie / Pro Action Replay 作弊码
  7. 蓝牙手柄 — 支持外接蓝牙游戏手柄

14. 参考资料


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐