在现代平台复活尼彩山寨机时代游戏

目录
项目名称:nieche-emu (CoolBar
.CBE模块高层模拟器)
项目地址:github.com/nieche-cbe-emu
支持平台:macOS (SwiftUI) / Android (Kotlin + Chaquopy) / Windows (Qt 6;另有实验性 Win32 前端) / Web(本地浏览器前端,开发调试用)
核心架构:Python(参照实现) + Rust(生产实现)双轨差分架构
代码规模:141 个源文件,源码共 35,717 行(物理行数,不含第三方依赖与构建缓存)
运行状态:全语料 35/35 模块 100% 跑通引导与主循环,300 帧差分测试零分叉
一、背景与研究对象
2010 年前后,以尼彩 I9 为代表的一批国产功能机在市场上大量普及。不同于当时占据主导地位的联发科(MTK)平台及其 MRE(.vxp)生态,这批机型基于晨星半导体(MStar)的 MSW8533 / 7835 芯片组,底层搭载基于 ARM926EJ-S 的微处理器,运行 CoolBar(51coolbar / 酷吧) 应用平台,其二进制可执行文件格式为 .CBE(CoolBar Executable)。
在 CoolBar 平台上,曾衍生出一批机制完整、体量在数兆字节以内的移动游戏(如动作 RPG《众神之战》、数值地牢《魔塔》、俯视射击《战争机器》、横版动作《恶魔城》、纵版空战射击《皇牌空战》、格斗类《武林外传》以及策略类《三国志》等)。
由于硬件生命周期终结以及官方网络服务器关停,加之该平台完全闭源且缺乏公开的 SDK 文档与接口规范,这批软件资产一度处于无法执行的断代状态。nieche-emu 项目的目标是通过黑盒逆向与固件分析,构建一套高层模拟器(HLE, High-Level Emulation),在现代操作系统上完整复原该平台的运行环境与交互能力。
二、系统整体架构设计
CoolBar 架构的核心特征是:游戏自带应用层引擎(包括 DreamFactory 引擎、XS_* 脚本虚拟机、GameLcd 绘图层),模块在初始化阶段会将自身的回调函数填入宿主的管理表(Manager Table)中;宿主仅负责提供底层抽象服务(显存帧缓冲、文件 I/O、内存分配、输入轮询、硬件定时器与音频播放)。
基于这一机制,模拟器无需逐条模拟游戏引擎的内部逻辑,而是采用高层模拟(HLE)架构:基于指令级模拟器(Unicorn Engine)仿真 CPU 与内存环境,宿主系统服务则由现代语言原生实现。
┌────────────────────────────────────────────────────────────────────────┐
│ 前端交互层 (UI Frontends) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ macOS (SwiftUI) │ │ Android (Kotlin)│ │ Windows (Qt 6) │ │
│ │ 进程通道解耦 │ │ 触控与虚拟按键 │ │ 桌面原生窗口接入 │ │
│ └─────────┬────────┘ └─────────┬────────┘ └──────────┬───────────┘ │
└────────────┼─────────────────────┼──────────────────────┼──────────────┘
│ │ Chaquopy + ctypes │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ 门面层 (Session / C ABI) │
│ emu/host.py (Python Session) / rust/emuffi (libnieche 导出层) │
│ 统一维护:输入位掩码整形、帧时钟步进、状态序列化、事件订阅 │
└──────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 模拟核心层 (Python / Rust 双轨) │
│ ┌─────────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ Unicorn Engine (ARMv5TE) │ │ HLE 宿主系统实现 │ │
│ │ - ARM926EJ-S 核心 (LE / BE-32) │ │ - 27 个 Manager 方法表分发 │ │
│ │ - RWPI / SB-relative 内存布局 │ │ - VFS (挂载真机 FAT 镜像) │ │
│ │ - 陷阱调度 (Trap) 与原生桩注入 │ │ - 帧缓冲管线 (240x400 RGB565)│ │
│ │ - 地址 0 兜底与异常防护 │ │ - 定时器队列 / 音频事件提取 │ │
│ └─────────────────────────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ 二进制与容器解析层 (cbelib) │
│ CBE 容器拆解 │ 多包容器与子包加载 │ 9位距离 LZ77 解压 │ 图片解码 │
└────────────────────────────────────────────────────────────────────────┘1. 模块容器结构
.CBE 容器分为单包容器与多包容器(恶魔城、孤岛、战争机器、僵尸先生等采用多包容器):
- 容器头:包含 Magic、加载基址(
load_base,PIC 模块为 0)、RO 段偏移与尺寸、RW 段尺寸、以及非 PIC 模块的真实镜像结束边界(image_end); - RO 段:包含编译后的 Thumb/ARM 指令及只读数据;
- 数据段(RW/ZI):用于存放全局状态;
- 资源索引与数据区:条目首字节为类型标签(
type 0原始 RGB565、type 1GIF 变体、type 2压缩脚本、type 3PNG、type 5/10文本与 MIDI)。
2. 内存映射拓扑
模拟器将 32 位虚拟地址空间划分为相互隔离的内存区域:
0x0000_0000 ┌──────────────────────────────────────┐
│ NULL_GUARD (128 KB, 全零映射 + 钩子) │ 防御空指针解引用
0x0100_0000 ├──────────────────────────────────────┤
│ RO 段 (代码段,读/写/执行) │ PIC 模块代码区(非 PIC 按 load_base 放)
0x2000_0000 ├──────────────────────────────────────┤
│ RW 段 (全局数据段,sb/r9 指向此处) │ 模块数据区
0x3000_0000 ├──────────────────────────────────────┤
│ STACK (1 MB,向下增长) │ 仿真线程栈
0x4000_0000 ├──────────────────────────────────────┤
│ HEAP (256 MB 动态内存) │ 供 malloc / free
0x5000_0000 ├──────────────────────────────────────┤
│ TRAP 区 (HLE 系统调用陷阱桩) │ 每槽一条 Thumb bx lr
0x5100_0000 ├──────────────────────────────────────┤
│ NATIVE 区 (高频取值器原生 Thumb 桩) │ 不进宿主,直接返回常量
0x6000_0000 ├──────────────────────────────────────┤
│ DATA (宿主结构区,仅首页可执行) │ 宿主 Manager 方法表/上下文
└──────────────────────────────────────┘三、整体代码规模与工程分布
整个工程由逆向分析工具链、Python 参照实现、Rust 高性能生产核心以及三个平台原生外壳构成。
经统计,项目维护的源码规模为 141 个源文件,总计 35,717 行代码(物理行数;已剔除第三方依赖库、构建缓存以及二进制资产,其中包含从 DWARF 自动生成的 vmspec.py / vmspec.rs 共 2,260 行):
1. 语言分布统计
| 开发语言 | 源码行数 (LOC) | 占比 | 承担的核心职责 |
|---|---|---|---|
| Rust | 15,379 | 43.1% | 生产模拟核心(emucore)、C ABI 导出层(emuffi)、实验性 Win32 前端(emuwin) |
| Python | 14,798 | 41.4% | 原型参照实现(emu/)、逆向分析与自动化差分工具链(tools/)、容器解析器 |
| Swift | 2,190 | 6.1% | macOS 原生 App(swiftapp/)、早期的点阵字库生成器(tools/fontgen/mkfont.swift) |
| C / C++ | 1,813 | 5.1% | Windows Qt 6 前端(qtapp/)、核心 C ABI 头文件(nieche.h) |
| Kotlin | 1,537 | 4.3% | Android 原生 App(emu-android/):界面、虚拟按键面板与修改器 |
| 总计 | 35,717 | 100% | 141 个源文件 |
2. 核心模块与目录工程拆解
nieche-emu/
├── rust/ (15,785 行 / 46 文件)
│ ├── emucore/ Rust 模拟器核心:机器层状态机、内存分配、HLE 宿主分发、图形栅格化
│ ├── emuffi/ 标准 C ABI 导出层(libnieche.{dylib,a})与引擎进程通信接口
│ ├── emuwin/ 实验性的轻量级原生 Windows (Win32) 前端(未随发布版分发)
│ └── cbelib/ CBE 容器解析、LZ77 解压与图片解码(仅依赖纯 Rust 的 miniz_oxide)
├── emu/ (8,926 行 / 17 文件)
│ ├── machine.py Unicorn 虚拟机封装、内存分段管理、Trap 派发器与异常捕获
│ ├── runtime.py 宿主上下文构建、主循环调度、屏幕栈管理、定时器队列
│ ├── api.py 按固件符号注册的 27 个 Manager 宿主 API 实现
│ ├── oldlib.py 老 SDK 游戏库(GAME_lcd、DF_PictureLibrary 等,照《动感骰子》内置实现移植)
│ ├── vmspec.py 从固件 DWARF 自动导出的 27 个 Manager、998 个方法槽位规范
│ ├── lcd.py / images.py 显存帧缓冲管理、Blit 拷贝与调色板处理
│ └── vfs.py 支持真机 FAT 镜像的联合挂载虚拟文件系统
├── tools/ (4,215 行 / 42 文件)
│ ├── axf.py 调用 llvm-dwarfdump 的 MStar 固件 AXF/ELF/DWARF2 符号查询工具
│ ├── baseline.py 双轨差分测试流水线驱动(逐帧画面与调用日志采集)
│ ├── cbedis.py / cbex.py 按虚拟地址反汇编引擎、CBE 资源提取解压工具
│ ├── keyprobe.py 逐位扫描推导模块按键位掩码的诊断工具
│ └── godwar.py 全自动化触控导航脚本(用于深入地图验证场景加载)
├── emu-android/ (2,304 行 / 9 文件)
│ └── app/src/main/ Android 原生外壳:Chaquopy 胶水层、触控坐标映射、帧缓冲池、九宫格键盘、修改器
├── swiftapp/ (2,081 行 / 8 文件)
│ └── *.swift macOS SwiftUI 原生外壳:引擎进程通道、游戏库与镜像源、Scale2x/3x 放大
├── qtapp/ (1,710 行 / 15 文件)
│ └── src/ Windows Qt 6 原生外壳:高性能渲染窗口与事件映射
└── cbelib/ (696 行 / 4 文件)
└── Python 版容器提取、图片解码(RGB565/GIF/PNG)与 9 位距离 LZ77 算法实现除上述源代码外,仓库中还包含自动化抽取的 spec/vm_managers.json(4,399 行,固件中各 Manager 的 DWARF 结构体原型)以及全语料 35 个模块的标准 300 帧基准轨迹库(spec/baseline/)。
四、AI Agent 辅助逆向与工程实践
在逆向分析、代码移植与深层 Bug 排查过程中,AI Agent 被深度引入到研发循环中,承担了符号挖掘、数据流反推、接口规格生成与差分调试辅助工作。
┌────────────────────────────────────────────────────────────────────────┐
│ AI Agent 辅助研发工作流 │
│ │
│ [固件 AXF / ELF] ───► [DWARF 解析与抽取] ───► 自动生成 vmspec.py/rs │
│ ▲ │
│ │ 提示工程辅助语法解析 │
│ │
│ [反汇编序列] ───► [数据流与调用约定逆向] ───► 识别反向参数与位掩码 │
│ ▲ │
│ │ 寄存器约束推导 │
│ │
│ [差分测试失败日志]───► [分叉点快速归因定位] ───► 定位第一个分叉的调用 │
│ ▲ │
│ │ 调用踪迹与内存内容比对 │
│ │
│ [Python 参照实现] ───► [跨语言同构移植] ───► 产出 Rust 生产核心 │
└────────────────────────────────────────────────────────────────────────┘1. 固件 DWARF 解析与 Manager 规格表自动化生成
通过工具从固件抽取出的 DWARF 符号数据极其庞大(spec/vm_managers.json 超过 4300 行)。AI Agent 被用于辅助编写模式抽取脚本,自动化解析 DWARF 的 DIE 结构,准确提取包含 27 个 Manager、998 个函数指针槽位的方法表结构,并自动生成 Python 侧的 emu/vmspec.py 与 Rust 侧的 vmspec.rs:
# emu/vmspec.py:由 DWARF 自动生成的入口表(节选)
SYS = {
0x004: ('VMGetIoManager', 'VmIoManagerTag'),
0x00c: ('VMGetLcdManager', 'VmLcdManagerTag'),
0x014: ('VMGetTimeManager', 'VmTimeManagerTag'),
0x034: ('VMGetScreenManager', 'VmScreenManagerTag'),
0x084: ('VMGetGameManagerOld', 'GameManagerOldTag'),
# ... 共 25 个 Get 入口;各 Manager 的方法槽位在 MGR 表里
}2. 反汇编片段的数据流分析与语义归纳
在缺乏符号的老 SDK 系统调用(vmDlSdk_old)分析中,人工逐个分析 664 个调用点极其耗时(固件只出厂了新 SDK,帮不上忙)。AI Agent 辅助完成了基于 Capstone 反汇编树的数据流追踪工作:
- 确定传参格式:分析出多参数包装用
stm把入参写进一块全局内存帧、调用后再从帧里读回返回值的约定,提炼出按宽度对齐取参的_old_unpack解包器(数字系统调用的还原在runtime.py里); - 判定布尔参数极性(新 SDK 的同类问题):在分析
Storage_Date参数时,通过对比《众神之战》两个调用点前后的数据流向(先填缓冲再调用 vs 调用之后才解析缓冲),确认第 4 个参数为isLoad而非isWrite,纠正了此前按参数位置猜测的错误; - 规避死代码干扰:静态解出的 46 个功能号中,有 11 个虽然定义了包装、但从任何可达路径都调用不到,确认为死代码后剔除,剩余 35 个活跃调用。
3. 差分测试(Differential Testing)中的分叉归因定位
在 Rust 核心移植期间,AI Agent 辅助分析两侧导出的逐帧调用踪迹(Trace Log),按"第一个分叉的调用"直接指向出问题的接口。两个典型案例:
- 真分叉:《钻石迷情3》第 188 帧画面分叉。比对调用序列发现 Rust 侧的 paint 回调中途被截断——模块 C 库残留的 ARM 半主机调用
svc 0xab在 Rust 侧没有挂中断钩子,Unicorn 报异常后整次回调中止。补上半主机处理后逐帧一致; - 假分叉:《战争机器》一度显示在第 260 帧分叉,最终查明是比对脚本的统计口径有误(把"画面分叉、调用序列一致"也算成了通过),导致多份基准的画面指纹长期过期。修正脚本、重生成基准后,全语料逐帧一致;
- 两者都避免了人工逐帧单步调试海量内存状态的巨大开销。
4. 跨语言语义一致性审查
从动态类型的 Python 向严苛类型系统的 Rust 移植时,存在大量隐式行为风险(如溢出截断、小端转换、无符号扩展等)。AI Agent 对移植代码进行了逐行对照审查,重点审查了“为什么不能按直觉写”的防御性注释,确保所有经验性规避逻辑(如 4 像素对齐、ASCII 打开模式、-1 失败句柄)在 Rust 核心中被原样复现。
五、核心逆向与工程难点深度解析
本节集中记录在逆向工程与模拟器落地过程中,攻克的全部深水区技术难点与具体代码实现。
1. 内存模型、运行时布局与安全隔离
(1)非 PIC 模块的 RW 基址对齐陷阱
非位置无关模块的代码段和数据段在编译时固定了绝对虚拟地址。不能简单地在 RO 后面做页面向上取整(align_up)来分配 RW 段,其真实边界由文件头中的 image_end 决定。在《武林外传V10》中,若未读取该字段,计算得到的 RW 基址会偏移 0x2e4 字节,导致静态初始化在 .data 里的 VmScreen 结构被算进全零的 .bss,读出来整块是 0。
# emu/machine.py: 修正非 PIC 模块的 RW 段定位
@staticmethod
def rw_place(mod):
end = getattr(mod, "image_end", 0) or 0
lo = mod.load_base
hi = mod.load_base + max(mod.image_size, len(mod.ro)) + 0x10000
if lo < end < hi:
return end
return mod.load_base + align_up(len(mod.ro), 16)(2)r9(Static Base)跨宿主调用状态保持
在 ARM RVCT 的 RWPI 约定中,r9 作为全局数据基址寄存器,真机固件在回调模块时不会重置它。《三国志》启动时是一个引导器程序,其自身并不使用 r9;在解包并装载真正的子游戏后,引导器会主动将 r9 修改为子游戏的数据区地址。如果宿主每次从 Python/Rust 回调进入模块时强制将 r9 设回引导器的 rw_base,会导致内嵌游戏在读取全局变量时全部读到零值,触发大量空指针解引用。
# 维护动态活跃的 r9 状态
self.r9_live = self.rw_base # 初始指向加载区,运行时允许随 CPU 上下文自发演进(3)地址 0 兜底区必须“填零”
模块经常调用未实现的函数指针(空指针)。为了防止模拟崩溃,在 0x0 ~ 0x20000(128KB)映射了防护区。早期版本在此区域填满了 ARM bx lr 指令(字节序列 1e ff 2f e1),试图让空指针调用立刻静默返回。
然而这直接破坏了《AppStore》的数据解析逻辑。 模块不仅将空指针当作函数调用,还会将其当作数据指针做长度与字段读取(如 p->count)。当它把指令字节 1e ff 2f e1 当成长度字段读出时,算出一个巨大的长度,随后一路越界。
- 正确做法:防护区内存必须全部写入
0x00,使模块内部的防御性检查(if (p->count == 0) return;)正常起效; - 调用拦截:通过 Unicorn 的
UC_HOOK_CODE代码执行钩子捕获落在该区域的执行动作,记下 LR、把返回值置 0 后emu_stop(),由外层从 LR 重新启动执行(在钩子里直接改 PC 不生效),将“数据读取”与“指令执行”彻底隔离。
# emu/machine.py: 空指针执行捕获与拦截
def _on_null_call(self, uc, address, size, user):
lr = uc.reg_read(UC_ARM_REG_LR)
self.null_calls[lr & ~1] += 1
self.setreg(0, 0) # 空调用的返回值给 0
self._resume_pc = lr # 在钩子里改 PC 不生效:记下续跑地址
uc.emu_stop() # 停下后由外层 call() 从 LR 重新 emu_start(4)大端模式下的全零滑行防护
对于语料中的 5 个大端模块(BE-32:两版《武林外传》、《歪歪猫》、《三国志》与《三国志!》),ARM 架构会将零字节指令序列 0x00000000 识别为:
andeq r0, r0, r0 ; 机器码 00 00 00 00,等价于 NOP若宿主结构内存(DATA_BASE)拥有可执行权限,当模块发生野指针跳转滑入这片区域时,CPU 会像滑冰一样在零内存中执行数十万条 NOP 指令滑过数兆字节,直到越界才停机,导致第一现场被彻底覆盖。
解决办法:宿主分配区整体严禁赋予执行权限(UC_PROT_READ | UC_PROT_WRITE),仅对必须运行跳板指令的第 0 个物理页单独开放执行权限(0x1000 字节)。
2. 两代 SDK 的 ABI 冲突与底层 ARM 跳板实现
在新旧两代 SDK 模块共存的环境下,入口 r0 参数存在致命歧义:
- 新 SDK 模块:
entry(VmSysCallRegParam *p),模块向*p写入AppStart与AppStop回调; - 老 SDK 模块:
entry(syscall_fn),模块把r0视为系统调用函数指针保存起来,后续通过bx r0发起调用。
《武林外传V10》(模块名 fighter)在入口处同时做了这两种操作:既把参数存进全局,又往它指向的结构里写回调,导致静态启发式判定无法正确区分。
工程实现方案:在宿主上下文基地址部署硬件级 ARM 执行跳板。
# emu/runtime.py: boot() 阶段的跳板初始化(节选)
def boot(self):
m = self.mach
self.style = self.entry_style()
# 1. 注册老 SDK 集中系统调用 Trap
tramp = m.new_trap("OldSysCall", self._old_syscall)
# 2. 在 host 上下文开头写入一条无条件跳转的 ARM 机器码
# 0xE51FF004 -> ldr pc, [pc, #-4],即加载紧随其后的字到 PC
m.w32(self.host + 0x00, 0xE51FF004)
m.w32(self.host + 0x04, tramp)
# 3. 执行模块入口函数,传入 self.host 指针
r = m.call(m.ro_base | 1, [self.host])
# 4. 检查 host 内存头部的覆盖情况
cb0 = m.r32(self.host + 0x00)
if cb0 != 0xE51FF004:
# 机器码被替换,确认模块为新 SDK,提取模块写入的回调地址
self.mod_cb0 = cb0
self.mod_cb1 = m.r32(self.host + 0x04)
# 若依然为 0xE51FF004,说明模块未写入回调,其已被老 SDK 当作系统调用函数指针持有
return r- 新模块运行
str rX, [r0]时,恰好用真实回调地址将跳板覆写破坏,后续宿主正常调用回调; - 老模块执行
blx host时,CPU 执行到跳板指令,直接被重定向进入OldSysCall分发器。
3. 老 SDK 数字系统调用的逆向与规约
老 SDK(vmDlSdk_old)共有 35 个活跃系统调用,参数多通过内存槽传递,返回值同样采用内存回写机制。
(1)Syscall 1950:应用注册与函数指针回填
模块启动时调用 syscall(1950, blk),blk 为参数结构体。
+0x00: 模块的主循环回调mod_cb0;+0x04: 模块的退出回调mod_cb1;+0x08: 供宿主回填函数指针的槽位。
若宿主将 +0x08 简单赋为状态码 1,模块随后执行 ldr r0, [r4, #8]; bx r0,在剥离 Thumb 标志位后(1 & ~1 = 0)直接跳入地址 0 造成崩溃。此处必须填入一个真实可调用的 Trap 桩;同时该系统调用的返回值(r0)也必须是一个可调用的对象:模块会把它当函数指针存进自己的全局,之后 bx 过去,返回 1 同样等于跳到地址 0。
(2)Syscall 2001:内存对象句柄反向写回
在老 SDK 模块中,获取底层 Manager 遵循特定序列:
h = syscall(143, 0); // 申请某个系统管理器,获得临时句柄
desc = { &out_ptr, h, 4 }; // 构建 Fetch 描述符
syscall(2001, &desc); // 请求宿主把句柄转换为实体写入 *out_ptr
如果模拟器仅在 r0 返回数据,out_ptr 内部将永远为 0,模块后续解引用该指针时立刻崩溃。模拟器必须支持精准的按长度字节写回:
# emu/runtime.py: Syscall 2001 的精确按位写回
def _old_fetch(self, mc):
desc = mc.arg(1)
if not desc:
mc.ret(0); return
ptr, handle, ln = mc.r32(desc), mc.r32(desc + 4), mc.r32(desc + 8)
if ptr and ln >= 4:
mc.w32(ptr, handle)
elif ptr and ln == 2:
mc.uc.mem_write(ptr, (handle & 0xFFFF).to_bytes(2, 'little' if mc.le else 'big'))
elif ptr and ln == 1:
mc.uc.mem_write(ptr, bytes([handle & 0xFF]))
mc.ret(1)(3)未导出短信 API 的硬编码地址算术(Address Arithmetic)
在计费与短信发送流程中,反汇编显示模块并未通过符号或 Manager 查找函数,而是采用了一段奇特的版本检测代码:
; 武林外传(新品) 逆向片段 RO+0x764c
ldr r0, [r4, #0x244] ; 读取表项 GetRemainDay 的函数指针
ldr r1, [r4, #0x240] ; 读取表项 GetPayNum 的函数指针
subs r2, r0, r1 ; 计算两个接口之间的函数地址差模块依据地址差判定固件版本(检测差值是否等于 2644、2636、2682 或 3764)。当匹配到 3764 时,模块直接在此基址上加上偏移 1376,并执行 bx 跳转到未导出的 vMSendSms 内部私有实现。
为了适配该逻辑,模拟器在分配此类 Manager 方法表时,人工在 Trap 槽位之间预留了对应的间距,使得两函数的距离精确满足模块的检验:
# emu/runtime.py: 精准构建非连续 Trap 槽布局
OLD_SMS_DIFF, OLD_SMS_ADD = 3764, 1376
def _old_gamelib_sms(self, addr):
m = self.mach
pay = m.new_trap("BILLING_GetPayNumByAppId@v3", ...)
# 强制跳进 3764 字节对应的槽位数量
m.next_trap += self.OLD_SMS_DIFF // 4 - 1
remain = m.new_trap("BILLING_GetRemainDay@v3", ...)
# 再次增加 1376 字节跨度,直接命中未导出的 vMSendSms
m.next_trap += self.OLD_SMS_ADD // 4 - 1
m.new_trap("vMSendSms@v3", send_sms)
m.w32(addr + 0x240, pay)
m.w32(addr + 0x244, remain)4. 运行时状态机、时钟与生命周期排错(以《众神之战》为例)
在推进复杂大型模块《众神之战》时,排查出四个关键问题:
排查链条与技术根因分析:
┌────────────────────────────────────────────────────────────┐
│ 1. 漏挂 API 导致观测盲区 │
│ 只钩了 DF_GetTResource,场景走 DF_GetStreamTResource │
└─────────────────────────────┬──────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 2. 解压流缓存指针失效导致野指针 │
│ 模块自主 free 内存,宿主缓存仍在返回旧地址,二次装载错位 │
└─────────────────────────────┬──────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 3. 变长字符串长度编码混淆 │
│ 场景文件(SCE2)用 u8 长度,脚本文件(XSE0)用 u32 长度 │
└─────────────────────────────┬──────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ 4. 虚拟时钟引发的脚本 VM 忙等死循环 │
│ 单帧内 Tick 恒定,死循环 30 万次;加入连续命中自旋检测 │
└────────────────────────────────────────────────────────────┘- 观测盲区纠正:游戏无法进入地图最初被误判为“未触发资源请求”,原因是早期仅拦截了
DF_GetTResource与DF_GetResourceIDByFileName,而游戏内部走的是DF_GetStreamTResource。补全钩子后调用链得以显现。 - 解压缓冲区的野指针污染:
GetStreamDataFormRes原本设计了一个解压结果缓存机制。然而该平台的内存管理权属于游戏模块,模块消费完数据后会显式调用free()将内存交还堆分配器。当分配器把该块内存重新分配给其他对象后,宿主缓存依然向模块返回老指针。在二次读取角色配置guangmingshen_jineng.actor时,读到的图片数量变成了随机大数 3094,解析器在未初始化的内存中疯狂爬行。真实语义为:宿主绝不缓存解压指针,每次均返回新分配的缓冲,交由模块全权释放。 DF_ReadString的编码分歧:DF_ReadString(场景文件 SCE2 使用)前缀是单字节u8长度;而DF_ReadString2(脚本虚拟机 XSE0 使用)前缀是 4 字节u32长度。当把DF_ReadString2错误当作u8读时,低字节读完后,高位 3 个 NUL 字节依然残留在流中,导致虚拟机操作码整体后错 3 字节,脚本解析全线崩溃。- 虚拟时钟忙等自旋检测:
脚本虚拟机中的延迟实现采用轮询方式:在真实硬件上时钟随晶振自增,但在模拟器中,虚拟时钟通常按帧边界推进。单帧之内 Tick 恒定不变,导致单帧内 CPU 执行了三十多万次死循环。若每次查询都推进时间,又会破坏根据 Tick 差值计算角色动画位移的正常逻辑。asm
0x11bcc: bl #0xdeaa ; 调用 VMGetTickCount() 获取当前毫秒数 0x11c48: ldr r2, [r0, #0x18] ; 读取目标唤醒时刻 0x11c4a: cmp r2, r6 0x11c4c: bgt #0x11bcc ; 没到时间就跳回死循环
解决方案:识别自旋状态——在运行时维护命中计数器,当且仅当 CPU 连续击中同一个系统调用 Trap 槽位且中间没有任何其他指令与宿主交互时,判定为处于忙等状态,微量步进虚拟 Tick 让其自然脱困。
5. 图像解码与图形渲染反直觉细节
(1)固件 Blit 反汇编细节:行对齐与反向坐标
固件绘制函数 vMDrawImageWithClipEx(地址 @0x530b3f)的反汇编证实了两个极其隐蔽的硬件细节:
- 显存行跨度公式:强制以 4 像素向上对齐: 若忽略该对齐,宽度不是 4 的倍数的图片在显存中画出的画面会逐行错位、整体倾斜撕裂。
- 参数传递顺序颠倒:前 8 个参数定义为:
(dst, src, srcX, srcY, w, h, dstX, dstY)
源坐标位于第 3/4 参数,目标坐标位于第 7/8 参数,与常规图形库顺序完全相反。
(2)Type-1 GIF 变体 3 字节偏移 Bug
在解析图像资源包时,Type-1 格式采用如下二进制布局:
┌──────────────┬────────────────────────┬─────────────────────────┐
│ 8 字节私有头 │ 大端 RGB565 调色板数组 │ 0x21 0xF9 0x04 图形扩展 │ ...
└──────────────┴────────────────────────┴─────────────────────────┘解析器最初在读取完调色板数组后,多执行了一句 offset += 3。这 3 字节的偏移使得指针恰好跳过了紧跟其后的 0x21 0xF9 0x04(GIF 图形控制扩展块,包含透明索引定义)。解析器直接落在了数据区,找不到 0x21 Magic,导致所有切片的透明信息丢失,精灵图全部带有实心黑框。删除该多余步进后,全语料 544 张切片图像的透明度 100% 恢复。
(3)动态分辨率认领机制
平台并非全部固定为 240×400。语料中有 6 个模块使用非标分辨率:《歪歪猫》为 176×220,《涂鸦跳跃》为 240×352,两版《武林外传》、《三国志》与《三国志!》为 240×320。这些游戏不会调用宿主接口查询分辨率,而是直接按自身的宽度执行全屏清屏(VMFillRectEx(0, 0, w, h, 0))或从原点设置剪裁矩形(SetClip)。模拟器通过在游戏启动的前 4 帧内捕获此类全屏操作,动态重置显存的逻辑尺寸,杜绝画面错位。
6. 虚拟文件系统(VFS)与存档陷阱
- 打开模式字符串为单字节 ASCII:
在宿主接口中,文件路径采用 UCS-2 宽字符编码,但文件模式mode参数却为单字节 ASCII(如"rb","w+")。由于单字符"r"在小端 UCS-2 下刚好是0x72 0x00,单字符读法碰巧兼容;但遇到"w+"(0x77 0x2b)时,若按 UCS-2 解析为 U+2B77,模式识别失败回退为只读,导致《战争机器》等游戏的存档与配置文件永远无法保存。 - 文件失败错误码为
-1,不能返回0:
固件语义中,0是第一个合法的文件句柄,-1才是打开失败标志。返回0会导致游戏误认为文件存在,并拿着0句柄读取出 0 字节,随后用malloc(0)返回的空指针进行结构体解析并崩溃。 - 部分覆盖更新必须使用
"r+b":
例如《众神之战》保存时,并不重新写入整个 163 字节的GodWarGameRecord存档,而是仅更新前 20 字节(读档时却要读 159 字节)。若宿主的Storage_Date用截断模式"wb"打开现有文件,存档会被砍成残缺的 20 字节,下次就读不出存档。已存在的存档文件必须用非截断的"r+b"打开、只覆盖前 n 字节。 Storage_Date极性反转修正:
该接口的布尔参数经反编译确认:传 0 为将内存序列化回写文件,传 1 为从文件读入内存。
7. 离线鉴权绕过与短信计费状态机
CoolBar 模块在启动时强制通过 wmix.kvbar.com 联网鉴权。模拟器通过将计费项查询接口 BILLING_GetPayNumByAppId 固定返回 0(未配置计费通道),驱动游戏内部状态机直接转入本地免校验启动分支,无需对游戏二进制打任何补丁即可纯离线运行。
针对部分试玩关卡结算时触发的计费弹窗(如《歪歪猫》试玩结束后的短信点播),模拟器通过在底层模拟短信异步回调状态机:
# 模拟底层 vMSendSms 回调
# 异步回调 cb(0) 代表发送成功,促使模块修改已付费标志继续关卡
self.defer(callback_ptr, (0,), "smsResult")使得原本依赖短信息扣费解锁的关卡内容在完全断网的环境下依然能够完整游玩。
六、架构演进:从 Python 参照机到 Rust 核心与差分测试
1. 显示管线与 FFI 瓶颈优化
在早期 Python 原型中,游戏运行帧率较低。通过 cProfile 剖析发现:99.7% 的 CPU 时间花在 Python 逐像素将 RGB565 显存转换为 RGB888 并用 PIL 编码为 PNG 的操作上,真实的 Unicorn 指令仿真仅占极小比例。
早期渲染链路:
Unicorn 显存 ──► Python 逐像素遍历转 RGB888 (47.5 ms) ──► PIL 编码 PNG (53.2 ms) ──► 前端
耗时: ~100 ms / 帧 (约 10 FPS)
重构后显示链路:
Unicorn 显存 ──► 一次 mem_read 取 RGB565 裸数据 (0.01 ms) ──► 前端自行转换并显示
核心侧耗时: 显示开销从约 100 ms 降到可以忽略优化手段:核心层彻底剔除图像编码,通过 C ABI 直接把原生的 raw565 连续帧数据交给前端(SwiftUI / Qt / Android / 浏览器),由前端完成色彩转换;macOS 端另可选 Scale2x/Scale3x 像素放大。
2. 高频宿主 API 的原生 Thumb 桩注入
剖析显示,VMGetCurrMainScreenImage(返回当前主屏幕显存缓冲指针)在《众神之战》中单帧被调用数百到 1500 多次(随场景而异),最多时占全部宿主系统调用次数的四分之三。每次调用都会产生从 CPU 虚拟层退出至宿主环境的上下文切换开销。
针对这类生命周期内返回值恒定不变的取值器函数,模拟器在 NATIVE 内存区直接动态烧录两条 Thumb 原生指令:
ldr r0, [pc, #0] ; 读取后方连续存储的 32 位常量
bx lr ; 直接返回调用方
.word <帧缓冲地址> ; 运行期固定不变的返回值Unicorn 执行这些函数时直接在内部完成寄存器赋值与返回,完全不再进入 Python / Rust 宿主拦截层,这一项就省掉了《众神之战》每帧约四分之三的宿主往返。
3. 双轨差分验证(Differential Testing)
为了确保在将模拟核心用 Rust 彻底重写时不引入任何行为退化,项目构建了跨语言的双轨差分测试流水线。
# tools/baseline.py:每帧记录三项指纹(节选)
for i in range(frames):
rt.mach.call_log.clear()
apply_input(rt, i)
rt.frame()
names = "\n".join(c[0] for c in rt.mach.call_log)
rows.append((i,
zlib.crc32(rt.fb.raw565()) & 0xFFFFFFFF, # 1. 帧缓冲 CRC32:抓"画错了"
zlib.crc32(names.encode()) & 0xFFFFFFFF, # 2. 调用名序列 CRC32:抓"走错了"
len(rt.mach.call_log))) # 3. 本帧调用次数- 判定准则:300 帧内画面 CRC32、API 调用名序列 CRC32、调用计数三项同时完全相同(调用指纹只哈希名字不哈希参数,因为两套实现的堆地址本来就可能不同);
- 测试结果:通过
tools/rsdiff.sh验证,Rust 生产核心在语料库全量 35/35 个模块 上与存档基准(由 Python 参照实现生成)300 帧逐帧一致。
七、多平台工程交付与辅助系统
1. 跨平台宿主架构
┌────────────────────────────────────────────────────────────────────────┐
│ nieche-core (Rust 核心) │
└──────────────────────────────────┬─────────────────────────────────────┘
│
┌────────────────────────────┼────────────────────────────┐
▼ (子进程标准流协议) ▼ (Chaquopy + ctypes) ▼ (Qt 6)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ macOS (SwiftUI) │ │ Android (Kotlin) │ │ Windows (Qt 6) │
│ NiecheEmu.app │ │ NiecheEmu.apk │ │ NiecheEmu.exe │
│ - 独立引擎进程 │ │ - 触控坐标映射 │ │ - 独立模拟线程 │
│ - 远端镜像源/钥匙串 │ │ - 九宫格功能机键盘 │ │ - 崩溃异常记录 │
│ - Scale2x/3x 放大 │ │ - 内存修改器 │ │ │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘- macOS 端:基于 SwiftUI 构建,核心作为独立引擎子进程运行,通过 stdin/stdout 二进制协议与 UI 进程通信。UI 订阅精准收窄至屏幕子视图,避免频繁重绘拖垮主线程;支持从远端公开镜像源(
index.json)拉取与校验游戏包。 - Android 端:Kotlin 外壳经 Chaquopy 内嵌一层 Python 胶水,再用 ctypes 调用交叉编译的
libnieche.so;屏幕下方集成了还原真机布局的虚拟键盘,并带内存修改器。横屏游戏需要把手机横过来玩(暂不支持画面旋转)。 - Windows 端:发布版基于 Qt 6,由 CI 构建;仓库里另有一个实验性的原生 Win32 前端(
rust/emuwin)。 - Web 端:
tools/run_web.py在本地起一个 HTTP 服务,浏览器长轮询取帧、POST 提交输入,主要用于开发调试。
2. 12×12 点阵中文字库
原机字库压在固件语言包的私有容器里,目前还没有还原出来。为了让游戏剧情正常展示,模拟器自带一份编码、索引和度量都与原机一致的等价字库 font12.cbef(GB2312 全部 8836 个字符槽位,汉字 12×12、ASCII 6×12 半角):
- 最早用
tools/fontgen/mkfont.swift借 macOS CoreText 把系统字体缩到 12px 再二值化,但笔画粘连、发糊; - 现在改用开源的 Fusion Pixel 12px 点阵字体(OFL 许可),由
tools/fontgen/bdf2cbef.py转换;它缺的 176 个字符中,31 个全角数学符号手绘,145 个生僻字取 GNU Unifont 压到 12×12; - 配合宿主
VMGetFontWidth(isFullWidth)接口严格按参数返回半角/全角像素步进——不看这个参数时汉字会全部叠在一起,看起来像字库坏了。
3. 通用内存修改器
核心层提供了内存搜索接口:在模块的 RW 数据段和堆里按数值搜索,数值变化后再筛选,剩下少数候选时可以改写或锁定(锁定值每帧写回)。Android 端的修改器还针对个别游戏做了专用选项:《三国志·赤壁传》可以直接设定武将等级、锁定武器耐久,《孤岛》可以直接修改生存点。
八、全语料回归验证与项目现状
回归脚本 python3 tools/batch.py 确认全语料 35 个模块全部跑进主循环。下表列出其中 15 个模块:非黑像素一栏是 Rust 核心在不做任何输入的情况下运行 300 帧后画面中的非黑像素数(分母为该模块的屏幕像素总数;此时多数游戏停在标题或菜单页,《众神之战》停在开场文字页,所以数值只说明画面已正常绘制);运行状态为手动操作进入实际游戏后确认。
| 模块名称 | 非黑像素(300 帧,无输入) | 运行状态 | 关键攻坚特征 |
|---|---|---|---|
| 众神之战.CBE | 1,389 / 96,000 | 可正常游玩 | 触摸导航、时钟自旋脱困、解压流野指针修复、局部存档回写 |
| 魔塔.CBE | 96,000 / 96,000 | 可正常游玩 | 完整点阵剧情文本排版、九宫格位掩码键盘映射 |
| 战争机器.CBE | 96,000 / 96,000 | 可正常游玩 | 多包容器解析(子包按名加载)、"w+" 单字节 ASCII 模式 |
| 皇牌空战.CBE | 93,407 / 96,000 | 可正常游玩 | 修正单双参数寄存器垃圾污染引起的空分配 |
| 武林外传(新品).CBE | 47,103 / 76,800 | 可正常游玩 | 老 SDK 35 个数字系统调用还原、Syscall 2001 句柄写回、短信地址差适配 |
| 武林外传V10.CBE | 47,205 / 76,800 | 可正常游玩 | 240×320 非标分辨率动态认领、老游戏库兼容表排布 |
| 歪歪猫发条城历险记.CBE | 23,222 / 38,720 | 可正常游玩 | 176×220 QCIF 屏自适应、平台文件空文件兜底、Logic 回调按键事件模型、短信点播异步回调 |
| 涂鸦跳跃.CBE | 84,480 / 84,480 | 可正常游玩 | 240×352 非标分辨率识别、按键与触摸双输入 |
| 恶魔城.CBE | 96,000 / 96,000 | 可正常游玩 | 多包容器解压、瓦片地图多层 Blit 混合 |
| 僵尸先生.CBE | 95,760 / 96,000 | 可正常游玩 | 子包对象、GBK 资源名、触摸事件、延迟换屏 |
| 三国志!.CBE | 71,019 / 76,800 | 可正常游玩 | 引导程序与内嵌游戏分离、r9_live 动态基址保持、内存池分配 |
| 极品飞车2012.CBE | 96,000 / 96,000 | 可正常游玩 | 伪 3D 赛道渲染、高速高频显存刷写 |
| 愤怒的小鸟.CBE | 96,000 / 96,000 | 可正常游玩 | 横屏游戏竖屏渲染、270 度旋转与触屏坐标反变换 |
| 捕鱼猎人.CBE | 96,000 / 96,000 | 可正常游玩 | Type-1 GIF 变体透明通道精准还原 |
| AppStore.CBE | 88,820 / 96,000 | 可正常交互 | 地址 0 全零填充防护、文件打开失败返回 -1 |
现存限制说明
- 纯网络模块:个别纯网络 MMORPG 模块(如《江湖OL》)在完成本地资源解包与界面初始化后,会停在「获取版本信息:正在和服务器通讯」,服务器早已关停,在缺乏服务端仿真前无法进入实际游玩;
- 出厂字模提取:当前使用的是编码与度量一致的替代点阵字库(Fusion Pixel 12px),固件内置私有字库容器的逆向尚未完成。
九、总结
通过对固件 DWARF 符号表的自动化挖掘、两代 SDK 冲突的 ARM 硬件级跳板设计、全量二进制系统调用的反推还原,以及 Python 到 Rust 的双轨差分测试,nieche-emu 成功在完全缺失官方技术资产的前提下,实现了对 CoolBar .CBE 嵌入式应用生态的高精度仿真。
目前项目工具链与多端产物已完整开源,涵盖了从 DWARF 提取工具、CBE 资源拆解器、反汇编分析脚本到多平台前端的全部源代码,见 GitHub 组织 nieche-cbe-emu。