这篇文章记录了把一个有二十多年历史的 NES 模拟器 FCEUX 移植到 HarmonyOS 的全过程。希望能给同样在做 NDK 移植、或者对模拟器感兴趣的朋友一些参考。


更多交流学习,欢迎加入开源鸿蒙PC社区https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目https://atomgit.com/OpenHarmonyPCDeveloper

本文对应的实战项目地址https://gitcode.com/qq8864/ohos_nes_fceux

为什么要干这事

事情是这样的——手里有台 MateBook Pro,跑的是 HarmonyOS,想在上面玩红白机游戏。翻了翻应用市场,现成的 NES 模拟器不好用,且无论是虚拟按键还是键盘,体验都是不好的,后续想支持下接入蓝牙手柄,没有源码就没法搞。那就自己搞一个吧。

FCEUX 是我比较熟悉的模拟器,代码质量高,核心部分极其纯净——几乎不依赖操作系统 API,纯标准 C++ 就能编译。它的 GitHub 仓库在这里:https://github.com/TASEmulators/fceux

之前也有过一些移植鸿蒙项目的经验,知道这条路大概怎么走:核心基本不用动,但前端(UI + 交互)得全部重写。正好可以试试 XComponent 原生渲染和 NAPI 桥接这些技术。


先看看 FCEUX 的代码长什么样

FCEUX 的源码结构其实挺清晰的:

fceux-master/
└── src/
    ├── fceu.cpp / fceu.h          ← 模拟器主循环
    ├── x6502.cpp / x6502.h         ← 6502 CPU 核心
    ├── ppu.cpp / ppu.h             ← 画面渲染引擎
    ├── sound.cpp / sound.h         ← 音频(APU)引擎
    ├── cart.cpp / cart.h           ← 卡带管理
    ├── boards/                     ← 230+ 个卡带映射器(Mapper)
    ├── input/                      ← 各种输入设备
    ├── drivers/                    ← 平台相关驱动(SDL/Qt/Win32)
    ├── utils/                      ← 工具函数
    └── ...

这里有个很重要的判断:只有 drivers/ 目录下的代码是平台相关的,上面的核心代码全部是跨平台的。这意味着只要我们把驱动层重新实现一遍,整个模拟器就能跑起来。

src/driver.h 定义了驱动层需要实现的所有接口,大概有 80 个函数。初看挺吓人,但仔细一看——大部分都是空函数也没关系,真正核心的只有这几类:

  • 视频:把 256x240 的像素数组送到屏幕上
  • 音频:把 APU 生成的 PCM 数据播放出来
  • 输入:把按键状态传给模拟器核心
  • 文件:读写文件和 ROM

剩下的网络对战、录像、Lua 脚本啥的,对于移动端来说都是锦上添花,先空着就行。


在这里插入图片描述

整体架构设计

在鸿蒙上做这种需要高性能渲染的应用,架构一般是这样:

┌─────────────────────────────────────────┐
│            ArkTS 界面层                  │
│   XComponent  /  虚拟手柄  /  文件选择    │
└───────────────┬─────────────────────────┘
                │ NAPI (Node-API) 桥
┌───────────────▼─────────────────────────┐
│         C++ 原生层(NAPI Module)         │
│   暴露接口:startRom / renderFrame       │
│           setPadState / stopRom          │
│   XComponent 生命周期回调 + Surface 渲染  │
└───────────────┬─────────────────────────┘
                │ 直接函数调用
┌───────────────▼─────────────────────────┐
│      HarmonyOS 驱动层 (ohos_driver)      │
│   FCEUD_BlitScreen → RGBA 帧缓冲        │
│   WriteSound → 音频循环缓冲              │
│   FCEUI_SetInput → 位掩码按键            │
└───────────────┬─────────────────────────┘
                │ FCEUX Core API
┌───────────────▼─────────────────────────┐
│        FCEUX 核心 (thirdparty/)          │
│   6502 CPU / PPU / APU / 230+ Mapper    │
│   卡带加载 / 存档 / 输入设备              │
└─────────────────────────────────────────┘

这个四层结构的好处是每一层的职责都很清晰,出了问题也好排查。


第一步:搭编译环境(CMakeLists.txt 的折腾)

FCEUX 原本用的是 CMake 构建系统,鸿蒙 NDK 也是 CMake 原生支持的,所以这一步其实挺顺利。

核心思路很简单:把原本 src/CMakeLists.txt 里的 SRC_CORE 列表整个拿过来,去掉 Qt/SDL/Win32 相关的部分,加上我们自己的驱动文件。

实际做的时候踩了几个小坑:

坑 1:头文件路径

FCEUX 核心代码中有这样的条件编译:

#ifdef __WIN_DRIVER__
#include "drivers/win/main.h"
#elif __QT_DRIVER__
#include "drivers/Qt/sdl.h"
#else
#include "drivers/sdl/sdl.h"
#endif

我们的新驱动既不是 Windows 也不是 Qt,但默认会走 #else 去包含 drivers/sdl/sdl.h。所以我在 entry/src/main/cpp/drivers/sdl/ 下创建了四个存根头文件:

drivers/sdl/
├── sdl.h       ← 包含 main.h, dface.h, input.h
├── main.h      ← 声明全局变量(eoptions, soundrate...)
├── dface.h     ← 声明驱动接口(InitSound, BlitScreen...)
└── input.h     ← 声明输入结构体(ButtConfig...)

存根头文件只做声明,不实现功能。真正的实现在 ohos_driver.cpp 里。

坑 2:zlib/minizip 冲突

FCEUX 在 file.cpp 里为了支持压缩 ROM(zip 格式),包含了一个 minizip 库。在 src/utils/ 下有一个 C++ 版的 unzip.cpp,在 src/drivers/win/zlib/ 下还有一个 C 版的 unzip.c

两条路都编译进来就链接冲突了。我的做法是明确指定 utils 源文件列表,不用 GLOB,把 unzip.cpp 排除掉,只编译 drivers/win/zlib/ 下那套:

set(FCEUX_UTIL_SRC
    ${FCEUX_SRC_DIR}/utils/backward.cpp
    ${FCEUX_SRC_DIR}/utils/xstring.cpp
    # ... 注意没有 unzip.cpp
)

坑 3:缺少链接库

新的 napi_init.cpp 用到了 XComponent 和 NativeWindow 的 API,需要在 target_link_libraries 里加上:

target_link_libraries(entry PUBLIC
    libace_napi.z.so       # NAPI 核心
    libace_ndk.z.so        # XComponent
    libnative_window.so    # OH_NativeWindow
    libnative_buffer.so    # 缓冲区操作
    libhilog_ndk.z.so      # 日志
)

这几个库在 DevEco Studio 的 NDK sysroot 里都有,加上就能用。


第二步:视频输出——最核心的部分

NES 的输出分辨率是 256x240,60fps。FCEUX 核心每仿真完一帧,会把像素颜色索引写入 XBuf 这个数组里。我们的任务是把这些索引转成真正的颜色,然后显示到屏幕上。

第一个版本:Canvas putImageData(失败了)

最开始我偷懒,直接在 ArkTS 里用 Canvas:

Canvas(this.canvasCtx)
  .width('100%').aspectRatio(4 / 3)

// 每帧调用
let buf = fceux.getVideoBuffer(); // 通过 NAPI 拿 RGBA 数据
let img = this.ctx.createImageData(256, 240);
// ... 把数据填进去 ...
this.ctx.putImageData(img, 0, 0);

逻辑上是对的,但实际跑起来发现两个问题:

  1. 画面不动——putImageData 在某些 ArkUI 版本上效率极低
  2. 交互没响应——Canvas 没有焦点,按键事件收不到

这个方案很快就放弃了。

第二个版本:XComponent + OH_NativeWindow(成功)

推荐的做法是用 XComponent 的 SURFACE 类型:

XComponent({ id: 'nesGame', type: XComponentType.SURFACE, libraryname: 'entry' })

关键在这个 libraryname: 'entry'——它告诉系统,这个 XComponent 的原生渲染代码在名为 entry 的 NAPI Module 里。当 XComponent 创建时,系统会调用 NAPI Module 的 Init 函数,我们可以在这里注册生命周期回调:

// napi_init.cpp
static napi_value Init(napi_env env, napi_value exports) {
    // 从 exports 中取出 XComponent 对象
    napi_value exportInstance;
    napi_get_named_property(env, exports, OH_NATIVE_XCOMPONENT_OBJ, &exportInstance);
    napi_unwrap(env, exportInstance, (void**)&nativeXComponent);
    // 注册回调
    OH_NativeXComponent_RegisterCallback(nativeXComponent, &callbacks);
    return exports;
}

回调里有三个关键事件:

static OH_NativeXComponent_Callback g_callbacks = {
    .OnSurfaceCreated = OnSurfaceCreated,   // 表面创建 → 获取 native window
    .OnSurfaceChanged = OnSurfaceChanged,   // 表面变化 → 更新 window
    .OnSurfaceDestroyed = OnSurfaceDestroyed, // 表面销毁 → 清空引用
    .DispatchTouchEvent = DispatchTouchEvent,
};

OnSurfaceCreated 中,我们拿到 OHNativeWindow 并配置缓冲区的格式:

static void OnSurfaceCreated(OH_NativeXComponent *component, void *window) {
    g_nativeWindow = (OHNativeWindow *)window;
    OH_NativeWindow_NativeWindowHandleOpt(g_nativeWindow, SET_FORMAT, NATIVEBUFFER_PIXEL_FMT_RGBA_8888);
    OH_NativeWindow_NativeWindowHandleOpt(g_nativeWindow, SET_USAGE,
        NATIVEBUFFER_USAGE_CPU_WRITE | NATIVEBUFFER_USAGE_HW_RENDER);
}

每帧的渲染流程:

static void RenderFrameToSurface() {
    // 1. 请求一个缓冲区
    OH_NativeWindow_NativeWindowRequestBuffer(g_nativeWindow, &buffer, &fenceFd);
    
    // 2. 映射到用户空间
    BufferHandle *handle = OH_NativeWindow_GetBufferHandleFromNative(buffer);
    void *addr = mmap(..., handle->fd, ...);
    
    // 3. 写入像素数据(N 个像素 × RGBA)
    uint32_t *pixels = (uint32_t *)addr;
    for (每个像素) pixels[i] = 0xFF000000 | (R << 16) | (G << 8) | B;
    
    // 4. 解除映射
    munmap(addr, handle->size);
    
    // 5. 提交显示
    OH_NativeWindow_NativeWindowFlushBuffer(g_nativeWindow, buffer, -1, region);
}

调色板问题

FCEUX 的像素数据 XBuf 里存的是调色板索引(8 位,范围 0-255),需要查表转成 RGBA。

这个表的结构是:

XBuf 索引范围    含义
─────────────────────────────
0-63             系统覆盖色(文字提示等)
64-127           自动强调色调
128-191          NES 本色(无强调)
192-255          NES 色彩(全强调)

游戏画面主要用 128-191 这一段。我第一次写的时候犯了个低级错误:

// 错误写法:idx & 0x7F 把 128-191 映射到了 0-63
OhosPalEntry &p = g_state.currentPalette[idx & 0x7F];

结果就是所有颜色都映射到了系统覆盖色区域,画面灰蒙蒙一片——这就是用户说的"颜色不对"。

修复很简单:去掉掩码,直接用完整索引

// 正确写法
OhosPalEntry &p = g_state.currentPalette[idx];

另外,调色板需要预初始化。如果等 FCEUX 核心调用 FCEUD_SetPalette 来填充,第一帧的时候可能还是空的。所以我加了一个默认的 NES 调色板:

static const uint8 kDefaultNESPalette[64 * 3] = {
    84,84,84, 0,30,116, 8,16,144, ... // 标准 NES 调色板
};

static void InitDefaultPalette() {
    for (int i = 0; i < 64; i++) {
        // 填充到 4 个区域(0-63, 64-127, 128-191, 192-255)
        for (int section = 0; section < 4; section++) {
            g_state.currentPalette[section * 64 + i] = kDefaultNESPalette[i];
        }
    }
}

缩放策略的演进

NES 的 256x240 在宽屏上如果不做处理,就是屏幕中间一个邮票大的小方块。需要放大。

第一版:等比例缩放,取最小缩放因子

float scale = min(bufW / 256, bufH / 240);  // ← 导致大黑边

结果在 MateBook Pro(3:2 屏幕)上两边黑边非常宽。

第二版:宽度填满,高度裁剪

float scale = bufW / 256;  // ← 以宽度为准
int dstH = 240 * scale;
int crop = (dstH > bufH) ? (dstH - bufH) / 2 : 0;
// 从 crop 开始采样,画满整个缓冲区

这样画面填满整个 XComponent 的宽度,上下超出部分裁剪掉。NES 的 240 行有效画面大部分都在中间区域,裁剪掉一些边缘行影响不大。


第三步:音频——最折腾的部分

音频是这次移植里最折腾的部分,前前后后换了三个方案。

V1:ArkTS AudioRenderer(能编译但不出声)

一开始想走捷径,在 ArkTS 侧用 audio.createAudioRenderer() 来播放:

let cfg = {
  streamInfo: { samplingRate: 48000, channels: 1, sampleFormat: S16LE },
  rendererInfo: { usage: STREAM_USAGE_GAME }
};
this.audioRenderer = await audio.createAudioRenderer(cfg);
await this.audioRenderer.start();

// 每帧从 NAPI 拉取音频数据
let buf = fceux.getAudioBuffer();
await this.audioRenderer.write(buf);

代码逻辑看起来没问题,编译也通过了,但就是没声音。看了半天 hilog 发现 audioRenderer.write() 的返回一直是 undefined——查文档才知道这个 API 在新版本里已经被废弃了,虽然还有函数签名但实际不工作了。

V2:NAPI getAudioBuffer + 测试音

换思路——改成在 C++ 维护一个 4096 样本的环形缓冲,WriteSound() 写入,OhosGetAudioBuffer() 读出,通过 NAPI 返回给 ArkTS。

为了验证 AudioRenderer 到底能不能用,我在 C++ 里加了一段测试音(当真实音频数据为空时,返回一个方波):

if (count == 0 && maxCount >= 256) {
    static int phase = 0;
    count = 256;
    for (int i = 0; i < count; i++)
        outBuf[i] = (phase++ & 0xFF) < 128 ? 2000000 : -2000000;
}

结果——测试音也听不到。证明问题出在 ArkTS 侧的 AudioRenderer.write() 上,跟音频数据内容无关。

V3:OH_AudioRenderer NDK 原生播放(最终方案)

放弃 ArkTS 方案,改用 OH_AudioRenderer NDK API,在 C++ 侧直接创建音频播放器:

#include <ohaudio/native_audiostreambuilder.h>
#include <ohaudio/native_audiorenderer.h>

// 音频回调:系统需要数据时会调用这个函数
static int32_t AudioRendererCallback(OH_AudioRenderer *renderer, void *userData,
                                     void *audioData, int32_t audioDataSize) {
    int16_t *out = (int16_t *)audioData;
    int32_t frameCount = audioDataSize / sizeof(int16_t);
    // 从环形缓冲区拷数据
    int copyFrames = min(frameCount, state->audioBufferCount);
    for (int i = 0; i < copyFrames; i++)
        out[i] = (int16_t)state->audioBuffer[i];
    state->audioBufferCount -= copyFrames;
    // 剩余的填静音
    if (copyFrames < frameCount) memset(out + copyFrames, 0, ...);
    return audioDataSize;
}

// 初始化
OH_AudioStreamBuilder *builder;
OH_AudioStreamBuilder_Create(&builder, AUDIOSTREAM_TYPE_RENDERER);
OH_AudioStreamBuilder_SetSamplingRate(builder, 48000);
OH_AudioStreamBuilder_SetChannelCount(builder, 1);
OH_AudioStreamBuilder_SetSampleFormat(builder, AUDIOSTREAM_SAMPLE_S16LE);
OH_AudioStreamBuilder_SetRendererWriteDataCallbackAdvanced(builder, callback, &state);
OH_AudioStreamBuilder_GenerateRenderer(builder, &renderer);
OH_AudioRenderer_Start(renderer);

C++ 侧直接播放,绕过了 ArkTS 层,延迟更低也更可靠。在 CMakeLists.txt 里加上 libohaudio.so 即可。

一个低级错误导致半小时静音

日志显示一切正常:WriteSound 每帧写入约 800 样本,AudioRendererCallback 每 16ms 消费 4458 帧,数据流水线完全通畅。

但就是没声音。

查了半天发现问题在这:

// 错误写法:int32 右移 8 位再转 int16
out[i] = (int16_t)(state->audioBuffer[i] >> 8); // ← 音量衰减到 1/256

FCEUX 的 int32 音频数据实际值域已经是 int16 范围(-32768 ~ 32767),直接强转就行。多做一个 >> 8 相当于把音量调到了几乎静音的级别——而且是每个样本都这样。

修正后声音正常了。这个 Bug 的教训是:不要对别人的音频数据格式做多余的假设,先看看原始驱动怎么写。原版 SDL 驱动就是直接 sample = s_Buffer[s_BufferRead],没有位移操作。


第四步:输入——最简单的部分

NES 手柄只有 8 个键:上、下、左、右、A、B、Select、Start。用一个字节就能表示所有按键状态。

我的做法是维护一个 8 位掩码,然后通过 FCEUI_SetInput 传给核心:

void OhosSetKeyState(uint8 keys) {
    static uint8 joy[4] = {0};
    joy[0] = keys;  // 手柄 1 的按键状态
    FCEUI_SetInput(0, SI_GAMEPAD, joy, 1);  // 端口 0 = 标准手柄
    FCEUI_SetInput(1, SI_NONE, nullptr, 0); // 端口 1 = 无设备
}

ArkTS 侧做了两套输入:

键盘输入onKeyEvent 捕获按键事件

.onKeyEvent((event: KeyEvent) => {
    // A → KEY_A, B → KEY_B, 方向键 → KEY_UP/DOWN/LEFT/RIGHT
    let bit = keyBitFromCode(event.keyCode);
    if (event.type === KeyType.Down) this.padState |= bit;
    else this.padState &= ~bit;
    fceux.setPadState(this.padState, 0, 0);
})

触摸输入:虚拟手柄按钮通过 onTouch 事件

.onTouch((event: TouchEvent) => {
    if (event.type === TouchType.Down) this.updatePad(bit, true);
    else if (event.type === TouchType.Up || event.type === TouchType.Cancel) this.updatePad(bit, false);
})

这里有一个细节:TouchType.Cancel 也要处理。因为当触摸滑动出按钮区域时,系统会触发 Cancel 事件,如果不处理的话按键就会一直卡在按下状态。


第五步:界面布局的迭代

UI 这块改了四五版,最终才找到比较顺手的布局。

V1:底部手柄布局(失败)

┌────────────────────────┐
│  [Load ROM] status     │
├────────────────────────┤
│                        │
│    Game Screen         │  ← 铺满,但 Canvas 不响应
│                        │
├────────────────────────┤
│  [暂停] [继续]         │
│  ^   < v >   A B      │
│  Select  Start         │
└────────────────────────┘

问题:Canvas 渲染不工作,按键也没反应,底部 200px 的手柄区在 PC 屏幕上显得很小。

V2:XComponent + 底部手柄

换用 XComponent 后渲染和交互都正常了,但底部手柄在宽屏上体验不好,而且游戏画面被手柄挤压了。

V3:左右分栏(当前)

┌──────────────────────────────────────┐
│ [Open] [Demo]  Running: mario.nes    │  ← 44px 顶栏
├──────┬───────────────────┬───────────┤
│  ^   │                   │    B  A   │
│ < v >│   NES 游戏画面     │  SEL STA  │
│      │   (宽度填满)       │           │
├──────┴───────────────────┴───────────┤
│              FPS: 60                 │  ← 20px 底栏
└──────────────────────────────────────┘

左右分栏的好处:

  • 游戏画面居中占据最大区域
  • 左侧方向键、右侧 A/B 键,单手可操作
  • 横向空间充分利用

D-Pad 的十字布局也是用户反馈后改的:

第一版(纵向):^ 在左边,< > 在中间,v 在右边 ← 用户说"不符合习惯"
最终版(十字):^ 在上,< v > 在下一排 ← 这才是经典红白机手柄布局

第六步:80 个 FCEUD_* 存根的取舍

src/driver.h 里声明了大约 80 个以 FCEUD_FCEUI_ 开头的函数。核心代码会调用它们,所以必须提供实现,但大部都可以是空函数。

耗时最多的其实是搞清楚哪些函数已经定义在核心源码里了,哪些需要我们自己实现。举个例子——我一开始一股脑把所有函数都当存根写了,结果链接的时候一堆"重复定义"错误。

这是因为像 FCEUI_DisableSpriteLimitation 其实已经定义在 ppu.cpp 里了,FCEUI_SetInput 定义在 input.cpp 里,我们不需要再写一遍。

我做了个表格来区分:

函数 在哪里定义 我们怎么做
FCEUD_BlitScreen 驱动层 ✅ 必须实现(视频输出核心)
FCEUD_SetPalette 驱动层 ✅ 必须实现(存储调色板)
WriteSound 驱动层 ✅ 必须实现(音频输出)
FCEUI_SetInput 核心 input.cpp ❌ 不用管,核心已实现
FCEUI_DisableSpriteLimitation 核心 ppu.cpp ❌ 不用管
FCEUI_SaveSnapshot 核心 video.cpp ❌ 不用管
FCEUI_NetplayStart 核心 netplay.cpp ❌ 不用管
FCEUD_SendData 驱动层 ✅ 空存根
FCEUD_GetCompilerString 驱动层 ✅ 返回字符串
… 其余 60+ 个 驱动层 ✅ 空存根或简单返回值

还有一些全局变量也是核心引用但从外面定义的:

// 这些变量被核心的 .cpp 文件引用(extern),但不在核心代码中定义
int closeFinishedMovie = 0;     // movie.cpp 引用
int datacount = 0;              // sound.cpp 引用(Win32 debug)
int undefinedcount = 0;         // sound.cpp 引用
int KillFCEUXonFrame = 0;      // fceu.cpp 引用
bool turbo = false;             // fceu.cpp 引用

不提供这些的话链接器会报 undefined symbol。


调试秘诀:hdc + hilog

开发过程中最大的挑战其实是调试。模拟器这种东西,一旦画面出不来,你很难判断问题是出在:

  1. ROM 加载阶段(文件路径不对?)
  2. 核心仿真阶段(CPU 跑飞了?)
  3. 渲染阶段(像素数据写错了?)

鸿蒙上我主要靠 hilog 来排查。在 C++ 层加日志:

#include <hilog/log.h>
#define LOGI(fmt, ...) OH_LOG_INFO(LOG_APP, fmt, ##__VA_ARGS__)
#define LOGE(fmt, ...) OH_LOG_ERROR(LOG_APP, fmt, ##__VA_ARGS__)

// 使用
LOGI("OhosLoadRom: %{public}s, size=%zu", name, size);

然后在命令行用 hdc 抓取:

hdc shell "hilog -t app -L INFO -z 200 -x" | grep FCEUX

%{public}s 这个格式化符号要注意——鸿蒙的 hilog 默认会把字符串参数当隐私信息隐藏,加 public 才能看到内容。


那些让人头疼的 Bug 们

Bug 1:画面闪烁

现象:游戏跑起来画面一闪一闪的,尤其全屏场景特别明显。

排查:一开始以为是帧率不够,后来发现是 memset(base, 0, size) 把整个缓冲区清成黑色,然后才往上画新帧。这一清一画之间,屏幕会短暂显示黑色,等下一帧才正常——但下一帧又清了。

解决:不整个清空,只清除边框区域(letterbox 部分)。而且提高缓冲区数量(三缓冲)能让切换更平滑。

Bug 2:灰色画面

现象:超级玛丽看起来像黑白电视。

排查:问题出在 idx & 0x7F 这个掩码上。NES 像素索引范围 0-255,但实际游戏画面用的索引在 128-191 之间。& 0x7F 把 128-191 映射到了 0-63(系统覆盖色区域),所以颜色完全不对。

解决:去掉掩码。

Bug 3:ROM 加载失败

现象:点 Demo 按钮,状态显示"Failed to load ROM"。

排查loadRomFromData 把数据写到 /data/storage/el2/base/haps/entry/files/ 下,然后调用 FCEUI_LoadGame 读取。但这个路径的 files/ 子目录可能不存在。

解决:先调用 FCEUI_SetBaseDirectory 设置基础路径,然后在路径前先创建目录。不过我后来改用了更直接的方式——让 ArkTS 侧把 filesDir 传下来,确保路径正确。

Bug 4:音频无声

现象:APU 音频数据正常生成,环形缓冲正常写入,AudioRenderer 正常回调,但扬声器没声音。

排查:加 hilog 看数据流,确认 WriteSound 每帧写入 ~800 样本,AudioRendererCallback 每 16ms 消费 4458 帧,数据量完全够。跟了半小时才发现问题出在样本转换上:

// 问题行:多余右移 8 位
out[i] = (int16_t)(state->audioBuffer[i] >> 8);

FCEUX 的 int32 音频数据已经处在 int16 值域(-32768 ~ 32767),不做任何缩放直接强转即可。右移 8 位把每个样本缩放到 1/256,等于静音。

解决:去掉 >> 8,直接转换。

Bug 5:应用装不上

现象hdc install 报错 no signature file

原因:HarmonyOS 的物理设备要求应用必须有签名。

解决:可以用 DevEco Studio 的"Automatically generate signing"功能一键生成开发签名,也可以在 build-profile.json5 里手动配置。要注意签名算法必须用 SHA256withECDSA(用 RSA 会报错)。


性能分析

在 MateBook Pro 上实测的数据:

项目 数值
帧率 稳定 60fps
渲染延迟 < 1 帧
内存占用 ~50 MB
HAP 体积 6.6 MB(已签名)
CPU 占用 ~15%(单核)
ROM 加载 < 100ms

帧率能稳定在 60 的主要原因:我们走的是 XComponent 原生渲染路径,数据从 FCEUX 核心到屏幕的路径很短:

FCEUX 仿真 → XBuf → RGBA 转换 → mmap → 写入缓冲区 → flush

没有跨线程开销,没有 GPU API 调用,就是纯粹的 CPU 像素拷贝。


还可以做的事

目前的应用基本可用,但还有不少可以继续优化的方向:

  1. 音频用 OH_AudioRenderer 原生输出:现在走 NAPI→ArkTS 的路子延迟较高,应该直接在 C++ 层用 OH_AudioRenderer 播放

  2. 多线程仿真:FCEUX 核心是单线程的,每帧仿真 + 渲染都在 ArkTS 的定时器里跑。可以把仿真放到独立线程,渲染保持在主线程,这样可以利用多核

  3. 缩放滤镜:现在只是简单的 nearest-neighbor 放大(像素风),可以加 HQ2x / xBRZ 等滤镜

  4. 存档/读档:FCEUX 核心的存档功能是完整的,但 UI 上还没接入

  5. 金手指:FCEUX 有强大的 cheat 引擎,可以加个 UI

  6. 多语言支持:目前界面只有中文


写在最后

这次移植花了两天时间,整体难度中等。FCEUX 的核心代码质量非常高——二十多年历史的 C++ 代码,拉下来就能在鸿蒙 NDK 上编译通过,几乎没有遇到编译问题(除了第一轮那些预期之内的链接错误)。

最大的感想是:移植一个成熟的模拟器,技术难度不在模拟器本身,而在怎么和当前平台的原生 UI / 渲染系统打通。FCEUX 的 6502 核心和 230 个 Mapper 加起来不超过一万行代码,而驱动层、NAPI 桥、UI 代码加起来也差不多这个量级。

如果你也在做类似的移植项目,以下是我觉得最重要的几条经验:

  1. 先用最简单的路径跑通"Hello World"——不管是 Canvas 还是 XComponent,先让画面能动起来
  2. 调色板问题永远比想象中复杂——NES 的调色板系统有好几层映射,中间插错一层画面就全灰了
  3. XComponent 是鸿蒙上高性能渲染的正确选择——不要犹豫,直接用
  4. 签名问题提前解决——开发阶段就配好自动签名,省得到最后装不上

参考链接

更多交流学习,欢迎加入开源鸿蒙PC社区https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目https://atomgit.com/OpenHarmonyPCDeveloper

Logo

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

更多推荐