把 FCEUX 移植到HarmonyOS鸿蒙PC:一个 NES 模拟器的移植笔记
这篇文章记录了把一个有二十多年历史的 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);
逻辑上是对的,但实际跑起来发现两个问题:
- 画面不动——
putImageData在某些 ArkUI 版本上效率极低 - 交互没响应——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
开发过程中最大的挑战其实是调试。模拟器这种东西,一旦画面出不来,你很难判断问题是出在:
- ROM 加载阶段(文件路径不对?)
- 核心仿真阶段(CPU 跑飞了?)
- 渲染阶段(像素数据写错了?)
鸿蒙上我主要靠 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 像素拷贝。
还可以做的事
目前的应用基本可用,但还有不少可以继续优化的方向:
-
音频用 OH_AudioRenderer 原生输出:现在走 NAPI→ArkTS 的路子延迟较高,应该直接在 C++ 层用
OH_AudioRenderer播放 -
多线程仿真:FCEUX 核心是单线程的,每帧仿真 + 渲染都在 ArkTS 的定时器里跑。可以把仿真放到独立线程,渲染保持在主线程,这样可以利用多核
-
缩放滤镜:现在只是简单的 nearest-neighbor 放大(像素风),可以加 HQ2x / xBRZ 等滤镜
-
存档/读档:FCEUX 核心的存档功能是完整的,但 UI 上还没接入
-
金手指:FCEUX 有强大的 cheat 引擎,可以加个 UI
-
多语言支持:目前界面只有中文
写在最后
这次移植花了两天时间,整体难度中等。FCEUX 的核心代码质量非常高——二十多年历史的 C++ 代码,拉下来就能在鸿蒙 NDK 上编译通过,几乎没有遇到编译问题(除了第一轮那些预期之内的链接错误)。
最大的感想是:移植一个成熟的模拟器,技术难度不在模拟器本身,而在怎么和当前平台的原生 UI / 渲染系统打通。FCEUX 的 6502 核心和 230 个 Mapper 加起来不超过一万行代码,而驱动层、NAPI 桥、UI 代码加起来也差不多这个量级。
如果你也在做类似的移植项目,以下是我觉得最重要的几条经验:
- 先用最简单的路径跑通"Hello World"——不管是 Canvas 还是 XComponent,先让画面能动起来
- 调色板问题永远比想象中复杂——NES 的调色板系统有好几层映射,中间插错一层画面就全灰了
- XComponent 是鸿蒙上高性能渲染的正确选择——不要犹豫,直接用
- 签名问题提前解决——开发阶段就配好自动签名,省得到最后装不上
参考链接
- FCEUX 源码:https://github.com/TASEmulators/fceux
- HarmonyOS NDK 文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ndk-development-overview
- XComponent NAPI 参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-ndk/ndk-oh_nativexcomponent
- NativeWindow API:https://developer.huawei.com/consumer/cn/doc/harmonyos-ndk/ndk-oh_nativewindow
- 本项目的 port_guide.md:看同目录下的
port_guide.md
更多交流学习,欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper
更多推荐



所有评论(0)