Reviving NiCai Copycat Phone Era Games on Modern Platforms

Contents
Project Name: nieche-emu (CoolBar
.CBEHigh-Level Emulator)
Repository: github.com/nieche-cbe-emu
Supported Platforms: macOS (SwiftUI) / Android (Kotlin + Chaquopy) / Windows (Qt 6; plus an experimental Win32 frontend) / Web (local browser frontend for development)
Core Architecture: Dual-track differential architecture: Python (reference) + Rust (production)
Codebase Scale: 141 source files, 35,717 lines of code (physical lines, excluding vendored dependencies and build caches)
Current Status: All 35/35 modules in the corpus boot and reach their main loops; zero divergence in 300-frame differential testing
I. Background and Target Platform
Around 2010, a wave of domestic Chinese feature phones, epitomized by the NiCai I9, became widespread on the market. Unlike the then-dominant MediaTek (MTK) platform and its MRE (.vxp) ecosystem, these handsets were built on MStar Semiconductor’s MSW8533 / 7835 chipsets with an ARM926EJ-S processor, and ran the CoolBar (51coolbar / 酷吧) application platform, whose executable modules use the .CBE (CoolBar Executable) format.
A library of mechanically complete mobile games, each only a few megabytes or smaller, was built for CoolBar. Examples include the action RPG GodWar (众神之战), the stat-based dungeon crawler Magic Tower (魔塔), the top-down shooter Gear of War (战争机器), the side-scrolling action game Demon Castle (恶魔城), the vertical-scrolling air-combat shooter Ace Air Combat (皇牌空战), the martial-arts fighting game My Own Swordsman (武林外传), and the strategy game Three Kingdoms (三国志).
With the hardware at the end of its life, the official servers shut down, and the platform entirely closed-source with no public SDK documentation or interface specifications, this software had become impossible to run. The nieche-emu project uses black-box reverse engineering and firmware analysis to build a high-level emulator (HLE) that faithfully recreates the platform’s runtime environment and interaction on modern operating systems.
II. Overall System Architecture
The core architectural paradigm of CoolBar is: applications ship with their own game engines (including the DreamFactory engine, XS_* script virtual machines, and the GameLcd graphics layer) and dynamically register callbacks into the host’s Manager tables during initialization; the host OS provides low-level abstractions (LCD framebuffers, file I/O, dynamic memory management, input polling, hardware timers, and audio playback).
Consequently, the emulator does not need to simulate the game engine internally. Instead, it adopts a High-Level Emulation (HLE) design: an instruction-level CPU core (Unicorn Engine) provides ARM execution and memory virtualization, while host OS services are implemented natively in modern programming languages.
┌────────────────────────────────────────────────────────────────────────┐
│ User Interface Frontends │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ macOS (SwiftUI) │ │ Android (Kotlin)│ │ Windows (Qt 6) │ │
│ │ Process-Isolated│ │ Touch & Keypad │ │ Native Window │ │
│ └─────────┬────────┘ └─────────┬────────┘ └──────────┬───────────┘ │
└────────────┼─────────────────────┼──────────────────────┼──────────────┘
│ │ Chaquopy + ctypes │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────────────┐
│ Facade Layer (Session / C ABI) │
│ emu/host.py (Python Session) / rust/emuffi (libnieche C ABI) │
│ Unified handling: input bitmask shaping, frame ticks, serialization │
└──────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Core Emulation Layer (Dual-Track Python / Rust) │
│ ┌─────────────────────────────────┐ ┌──────────────────────────────┐ │
│ │ Unicorn Engine (ARMv5TE) │ │ HLE Host OS Services │ │
│ │ - ARM926EJ-S Core (LE / BE-32) │ │ - 27 Manager Method Tables │ │
│ │ - RWPI / SB-relative Memory Map │ │ - VFS (Real FAT FS Overlay) │ │
│ │ - Trap Interception & Stubs │ │ - Framebuffer (240x400 565) │ │
│ │ - Address 0 Zero Guard & Faults │ │ - Timers & Audio Events │ │
│ └─────────────────────────────────┘ └──────────────────────────────┘ │
└──────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────────────┐
│ Binary Extraction & Codec Layer (cbelib) │
│ CBE Containers │ Multi-Package Parsing │ 9-bit LZ77 │ Graphic Codecs │
└────────────────────────────────────────────────────────────────────────┘1. Container Structure
.CBE binaries exist as either single-package containers or multi-package archives (used by Demon Castle, Lost in Island, Gear of War, and Mr. Zombie, among others):
- Header: Contains magic markers, load base address (
load_base, 0 for PIC modules), RO segment offset and size, RW segment size, and non-PIC image boundaries (image_end); - RO Segment: Compiled Thumb/ARM machine instructions and constants (
.text+.rodata); - RW/ZI Segment: Writable global state (initialized
.dataplus zero-initialized.bss); - Resource Index & Payloads: Each entry begins with a 1-byte type tag (
type 0raw RGB565,type 1GIF variant,type 2compressed scripts,type 3PNG,type 5/10text/MIDI).
2. Memory Topology
The emulator partitions the 32-bit virtual address space into isolated regions:
0x0000_0000 ┌──────────────────────────────────────┐
│ NULL_GUARD (128 KB, Zeroes + Hook) │ Defends against null dereferences
0x0100_0000 ├──────────────────────────────────────┤
│ RO Segment (Code, RWX) │ PIC module code (non-PIC at load_base)
0x2000_0000 ├──────────────────────────────────────┤
│ RW Segment (Globals, sb/r9 target) │ Module data area
0x3000_0000 ├──────────────────────────────────────┤
│ STACK (1 MB, grows downward) │ Guest call stack
0x4000_0000 ├──────────────────────────────────────┤
│ HEAP (256 MB dynamic memory) │ Guest malloc / free
0x5000_0000 ├──────────────────────────────────────┤
│ TRAP Area (HLE Interception Stubs) │ One Thumb bx lr per slot
0x5100_0000 ├──────────────────────────────────────┤
│ NATIVE Area (Native Thumb Stubs) │ Constant getters, no host exit
0x6000_0000 ├──────────────────────────────────────┤
│ DATA (Host Structures, 1st Page X) │ Host Manager tables / context
└──────────────────────────────────────┘III. Codebase Scale and Distribution
The project comprises reverse-engineering tools, a Python reference implementation, a high-performance Rust production engine, and native frontends for three operating systems.
Excluding third-party dependencies, build artifacts, and binary assets, the codebase spans 141 source files and 35,717 lines of code (physical lines, including 2,260 lines of vmspec.py / vmspec.rs generated from DWARF):
1. Language Breakdown
| Language | Lines of Code (LOC) | Percentage | Core Responsibilities |
|---|---|---|---|
| Rust | 15,379 | 43.1% | Production emulator (emucore), C ABI layer (emuffi), experimental Win32 app (emuwin) |
| Python | 14,798 | 41.4% | Reference implementation (emu/), reversing & differential test tools (tools/) |
| Swift | 2,190 | 6.1% | macOS native SwiftUI app (swiftapp/), early dot-matrix font generator (tools/fontgen/mkfont.swift) |
| C / C++ | 1,813 | 5.1% | Windows Qt 6 frontend (qtapp/), core C ABI header (nieche.h) |
| Kotlin | 1,537 | 4.3% | Android native app (emu-android/): UI, virtual keypad, memory editor |
| Total | 35,717 | 100% | 141 source files |
2. Component and Directory Breakdown
nieche-emu/
├── rust/ (15,785 LOC / 46 files)
│ ├── emucore/ Rust core: machine state, memory allocators, HLE dispatcher, blitters
│ ├── emuffi/ Standard C ABI export (libnieche.{dylib,a}) and engine IPC channels
│ ├── emuwin/ Experimental lightweight native Windows (Win32) frontend (not shipped)
│ └── cbelib/ Container parser, LZ77 decompressor, and image codecs (only depends on pure-Rust miniz_oxide)
├── emu/ (8,926 LOC / 17 files)
│ ├── machine.py Unicorn wrapper, memory segmentation, trap dispatcher, fault handlers
│ ├── runtime.py Host context setup, main loop scheduling, screen stack, timer queue
│ ├── api.py HLE implementations for 27 managers registered by firmware symbols
│ ├── oldlib.py Legacy SDK game library (GAME_lcd, DF_PictureLibrary, ...), ported from the copy built into *Dynamic Dice (动感骰子)*
│ ├── vmspec.py Auto-generated spec of 27 Managers / 998 method slots from firmware DWARF
│ ├── lcd.py / images.py Framebuffer management, clipping Blit operations, color palettes
│ └── vfs.py Union VFS mounting actual phone FAT partition images
├── tools/ (4,215 LOC / 42 files)
│ ├── axf.py DWARF2 symbol and DIE query tool for MStar firmware (wraps llvm-dwarfdump)
│ ├── baseline.py Dual-track differential testing suite (frame and call-trace loggers)
│ ├── cbedis.py / cbex.py Virtual address disassembler, CBE resource archive unpackers
│ ├── keyprobe.py Bitwise scanner to discover undocumented module keymasks
│ └── godwar.py Touch navigation script for automated in-game map progression
├── emu-android/ (2,304 LOC / 9 files)
│ └── app/src/main/ Native Android UI: Chaquopy glue, touch mapping, frame pool, keypad, memory editor
├── swiftapp/ (2,081 LOC / 8 files)
│ └── *.swift macOS SwiftUI UI: engine process pipes, game library and mirrors, Scale2x/3x
├── qtapp/ (1,710 LOC / 15 files)
│ └── src/ Windows Qt 6 UI: high-performance frame canvas and input events
└── cbelib/ (696 LOC / 4 files)
└── Python container decoder, image converters (RGB565/GIF/PNG), and 9-bit LZ77 engineAdditionally, the repository maintains spec/vm_managers.json (4,399 lines of machine-extracted DWARF structure definitions) and 35 baseline execution traces in spec/baseline/ used for regression testing.
IV. AI Agent-Assisted Reverse Engineering and Engineering Practice
AI agents were integrated directly into the engineering workflow, handling symbol indexing, data-flow reconstruction, specification code generation, and differential debugging triage.
┌────────────────────────────────────────────────────────────────────────┐
│ AI Agent Collaboration Workflow │
│ │
│ [Firmware AXF / ELF] ──► [DWARF Parsing & Symbols] ─► Auto-Generate │
│ ▲ vmspec.py/rs │
│ │ Agent-Written Extraction Scripts │
│ │
│ [Disassembly Traces] ──► [Calling Convention Deduce] ─► Parameter Map │
│ ▲ & Keymasks │
│ │ Register Dependency Tracing │
│ │
│ [Differential Dumps] ──► [Divergence Root Cause] ─► First Diverg- │
│ ▲ ing Call │
│ │ Call-Trace & Memory Comparison │
│ │
│ [Python Prototype] ──► [Isomorphic Rust Port] ─► Production │
│ Rust Core │
└────────────────────────────────────────────────────────────────────────┘1. Firmware DWARF Parsing and Manager Specification Automation
The raw DWARF symbol dumps from the firmware image were massive (spec/vm_managers.json spans over 4,300 lines). AI agents helped design and refine the parsing pipeline, extracting exact C structure layouts across 27 Managers and 998 function pointer slots to automatically generate emu/vmspec.py and vmspec.rs:
# emu/vmspec.py: entry table auto-generated from DWARF (excerpt)
SYS = {
0x004: ('VMGetIoManager', 'VmIoManagerTag'),
0x00c: ('VMGetLcdManager', 'VmLcdManagerTag'),
0x014: ('VMGetTimeManager', 'VmTimeManagerTag'),
0x034: ('VMGetScreenManager', 'VmScreenManagerTag'),
0x084: ('VMGetGameManagerOld', 'GameManagerOldTag'),
# ... 25 Get entries in total; each Manager's method slots live in the MGR table
}2. Disassembly Data-Flow Analysis and Semantics Deduction
For the legacy SDK syscall dispatch (vmDlSdk_old), which completely lacked debugging symbols, manually inspecting 664 call sites was prohibitively slow (the firmware itself only shipped the new SDK, so it could not help). AI agents assisted in tracking register flow trees across Capstone disassembly:
- Determining parameter packing: Identifying that multi-argument wrappers
stmtheir arguments into a global memory frame and read the return value back from that frame after the call, formalized as the width-aligned_old_unpackdecoder (the numeric syscalls themselves are reconstructed inruntime.py); - Resolving boolean parameter polarity (the same problem on the new-SDK side): In
Storage_Date, comparing the data flow around GodWar’s two call sites (buffer filled before the call vs. buffer parsed after the call) confirmed that argument 4 isisLoadrather thanisWrite, correcting an earlier guess based on argument position; - Eliminating dead code: Of the 46 function numbers recovered statically, 11 had wrappers but were unreachable from any live path; once confirmed as dead code they were dropped, leaving 35 active syscalls.
3. Pinpointing Differential Divergence
During the Rust port, an AI agent compared per-frame call traces exported by both implementations; the first diverging call points straight at the faulty interface. Two typical cases:
- A real divergence: Diamond Twister 3 (钻石迷情3) diverged at frame 188. The call sequences showed the Rust side’s paint callback being cut short: an ARM semihosting call (
svc 0xab) left in the module’s C library had no interrupt hook on the Rust side, so Unicorn raised an exception and aborted the callback. Adding semihosting support made every frame match; - A false divergence: Gear of War once appeared to diverge at frame 260. This turned out to be a counting bug in the comparison script (it also counted “framebuffer differs, call sequence matches” as a pass), which had let several baselines’ framebuffer fingerprints go stale. After fixing the script and regenerating the baselines, the whole corpus matched frame for frame;
- Both avoided tedious manual step-by-step memory debugging.
4. Cross-Language Semantic Verification
Translating from dynamic Python to strictly typed Rust introduces subtle behavioural risks (e.g., arithmetic truncation, endianness assumptions, sign extension). AI agents performed line-by-line semantic audits of the ported routines, verifying that defensive engineering rules (e.g., 4-pixel stride padding, ASCII open modes, -1 error handles) were faithfully preserved.
V. Core Reverse Engineering and Technical Deep Dives
This section details the critical reverse-engineering challenges and bug investigations resolved during the development of the emulator.
1. Memory Models, Runtime Layout, and Isolation
(1) Non-PIC Segment Placement Traps
Non-position-independent modules have fixed load addresses. Their RW segment cannot simply be placed at the next page boundary following the RO segment; its exact location is determined by image_end in the container header. In My Own Swordsman V10, miscalculating this by 0x2e4 bytes caused statically initialized VmScreen structures in .data to map into zeroed .bss, preventing the main loop from starting.
# emu/machine.py: Resolving RW base for non-PIC binaries
@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) Preserving r9 (Static Base) Across Host Callbacks
Under the ARM RVCT RWPI convention, r9 is the static base register, and the real firmware does not reset it when calling back into a module. Three Kingdoms boots via an outer launcher that does not use r9; after unpacking and jumping into the nested game, it points r9 to the inner game’s data section. If the host resets r9 to the launcher’s rw_base on every host callback, the inner game reads all globals as zero, triggering immediate null pointer crashes.
# Maintaining live r9 state across host boundaries
self.r9_live = self.rw_base # Initialized to load base, evolves with guest execution(3) Zero-Filling Address 0
Modules frequently invoke unpopulated function pointers (NULL). To prevent simulation aborts, a 128 KB guard page is mapped at 0x0. Early versions filled this region with ARM bx lr instructions (bytes 1e ff 2f e1) to turn null calls into no-ops.
This broke data parsing in modules like AppStore.CBE. The binary treats null pointers not only as callable hooks, but also as data structures (e.g., reading p->count). Reading the instruction bytes 1e ff 2f e1 as a length field produced a huge length and a run of out-of-bounds reads.
- The correct approach: Address
0x0must be filled entirely with0x00, enabling the guest’s internal null-check logic (if (!p->count) return;) to execute properly; - Call interception: Execution attempts in this range are captured via Unicorn’s
UC_HOOK_CODE. The handler records LR, sets the return value to 0 and callsemu_stop(); the outer driver then restarts execution at LR (changing PC inside the hook has no effect).
# emu/machine.py: Intercepting execution on address 0
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) # a null call returns 0
self._resume_pc = lr # writing PC inside the hook has no effect: remember where to resume
uc.emu_stop() # the outer call() restarts emu_start from LR(4) Preventing Zero-Slide in Big-Endian Mode
For the five Big-Endian (BE-32) modules in the corpus (both editions of My Own Swordsman, WaiWai Cat, Three Kingdoms and Three Kingdoms!), ARM decodes a stream of zeroes (0x00000000) as:
andeq r0, r0, r0 ; Machine code 00 00 00 00, effectively a NOPIf the host data area (DATA_BASE) is executable, a wild jump into uninitialized zeroed memory causes the CPU to slide through megabytes of NOPs before crashing out of bounds, destroying the original crash context.
To prevent this, the host structure area is mapped non-executable (UC_PROT_READ | UC_PROT_WRITE), with execution rights granted strictly to the first 4 KB page where the entry trampoline lives.
2. Dual SDK ABI Collision and Dynamic Trampoline Dispatch
The coexistence of two generations of SDKs created a direct contradiction at the entry point RO+0:
- New SDK:
entry(VmSysCallRegParam *p)— The module writes itsAppStartandAppStopcallbacks into*p; - Old SDK:
entry(syscall_fn)— The module treatsr0as a syscall entrypoint and invokes it viabx r0.
My Own Swordsman V10 (module name fighter) does both at its entry point: it stores the argument in a global and also writes callbacks into the structure it points to, so static heuristics cannot tell the two apart reliably.
Solution: A hardware-level ARM trampoline placed at the host context base.
# emu/runtime.py: Dynamic entry trampoline in boot() (excerpt)
def boot(self):
m = self.mach
self.style = self.entry_style()
# 1. Register legacy syscall trap
tramp = m.new_trap("OldSysCall", self._old_syscall)
# 2. Write an ARM branch instruction at host+0:
# 0xE51FF004 -> ldr pc, [pc, #-4], which loads the word immediately following it into PC
m.w32(self.host + 0x00, 0xE51FF004)
m.w32(self.host + 0x04, tramp)
# 3. Execute module entrypoint with host address in r0
r = m.call(m.ro_base | 1, [self.host])
# 4. Check if the memory header was overwritten
cb0 = m.r32(self.host + 0x00)
if cb0 != 0xE51FF004:
# Instruction replaced: confirms New SDK; extract AppStart and AppStop
self.mod_cb0 = cb0
self.mod_cb1 = m.r32(self.host + 0x04)
# If unchanged, confirms Old SDK; module retains self.host as its syscall handler
return r- New modules run
str rX, [r0], overwriting the trampoline with valid callback addresses; - Old modules execute
blx host, run theldr pcinstruction, and are redirected straight intoOldSysCall.
3. Reversing Legacy SDK Numeric System Calls
The legacy SDK (vmDlSdk_old) routes all interactions through 35 active numeric syscalls, passing arguments via memory frames and returning objects through memory descriptors.
(1) Syscall 1950: Application Registration
Modules invoke syscall(1950, blk) where blk is an argument frame:
+0x00: Main loop callbackmod_cb0;+0x04: Exit callbackmod_cb1;+0x08: Output slot for host helper function pointer.
If the host writes status code 1 into +0x08, the module executes ldr r0, [r4, #8]; bx r0. Masking off the Thumb bit (1 & ~1 = 0) branches to address 0. The host must populate +0x08 with a valid trap stub and return a valid context pointer in r0.
(2) Syscall 2001: Descriptor Handle Writeback
To fetch manager tables, legacy modules invoke:
h = syscall(143, 0); // Request manager, obtain temporary handle
desc = { &out_ptr, h, 4 }; // Construct fetch descriptor
syscall(2001, &desc); // Host resolves handle and writes instance into *out_ptr
If the host only writes the return value to register r0, out_ptr remains null, triggering immediate crashes on subsequent table lookups. The host must write the value directly into the guest pointer:
# emu/runtime.py: Syscall 2001 handle resolution
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) Undocumented SMS Address Arithmetic
In billing routines, disassembled code revealed an unusual firmware version check:
; My Own Swordsman (New Edition) RO+0x764c
ldr r0, [r4, #0x244] ; Address of GetRemainDay in vtable
ldr r1, [r4, #0x240] ; Address of GetPayNum in vtable
subs r2, r0, r1 ; Compute address deltaThe binary uses this delta to identify firmware versions. When the delta equals 3764, the code adds 1376 to the base and branches directly (bx) to the unexported firmware function vMSendSms.
The emulator accommodates this by intentionally spacing out its trap slot allocator so the resulting virtual pointers satisfy the expected offsets:
# emu/runtime.py: Constructing non-contiguous trap layouts
OLD_SMS_DIFF, OLD_SMS_ADD = 3764, 1376
def _old_gamelib_sms(self, addr):
m = self.mach
pay = m.new_trap("BILLING_GetPayNumByAppId@v3", ...)
# Force slot allocator forward by 3764 bytes
m.next_trap += self.OLD_SMS_DIFF // 4 - 1
remain = m.new_trap("BILLING_GetRemainDay@v3", ...)
# Advance by 1376 bytes to position unexported 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. Runtime State, Clocks, and Lifecycle Traps (GodWar)
Investigation of GodWar resolved four cascading issues:
- Expanding the Observation Surface: Early attempts appeared to stall without requesting maps because hooks were attached only to
DF_GetTResourceandDF_GetResourceIDByFileName, while scenes were fetched viaDF_GetStreamTResource. - Decompressed Stream Cache Invalidation:
GetStreamDataFormResinitially cached decompression pointers. However, memory ownership belongs to the guest module, which callsfree()after consuming data. Once the heap allocator reallocated that buffer, the host cache continued returning the stale pointer. On reload,guangmingshen_jineng.actorinterpreted garbage memory as an image count of 3,094, hanging the parser. The correct behavior: decompression buffers must be freshly allocated on every request and left for the guest to free. - String Length Encoding Discrepancies:
DF_ReadStringuses a 1-byte (u8) length prefix in scene files (SCE2), whileDF_ReadString2uses a 4-byte (u32) prefix in script files (XSE0). ReadingDF_ReadString2asu8left three NUL bytes in the stream, desynchronizing the script bytecode parser. - Virtual Clock Busy-Wait Detection:
Script VM wait loops poll
VMGetTickCount():In an emulator where time steps between frame boundaries, the tick remains static within a single frame, causing hundreds of thousands of spin iterations.asm0x11bcc: bl #0xdeaa ; Query VMGetTickCount() 0x11c48: ldr r2, [r0, #0x18] ; Target wake tick 0x11c4a: cmp r2, r6 0x11c4c: bgt #0x11bcc ; Spin if current tick < target
Solution: Spin Detection—When the same trap slot is struck repeatedly with no intervening host operations, the runtime recognizes a busy-wait and increments the virtual tick counter.
5. Graphics Pipelines and Codec Anomalies
(1) Firmware Blit Disassembly: Stride Alignment and Swapped Arguments
Disassembling vMDrawImageWithClipEx (@0x530b3f) confirmed two non-standard behaviors:
- Stride Padding: Scanlines are padded to multiples of 4 pixels: Missing this padding makes images whose width is not a multiple of 4 shear row by row across the display buffer.
- Swapped Coordinates: The arguments are ordered as:
(dst, src, srcX, srcY, w, h, dstX, dstY)
Source coordinates are passed in slots 3/4, while destination coordinates are passed in slots 7/8.
(2) The 3-Byte Offset Bug in Type-1 GIF Decoding
Type-1 resources wrap GIF data in a custom container:
┌──────────────┬────────────────────────┬─────────────────────────┐
│ 8-Byte Head │ Big-Endian Palette │ 0x21 0xF9 0x04 GCE │ ...
└──────────────┴────────────────────────┴─────────────────────────┘The original parser advanced the stream offset by an extra 3 bytes (offset += 3) after reading the palette. This skipped over the 0x21 0xF9 0x04 Graphic Control Extension containing the transparency index. The subsequent 0x2C image descriptor parsed without error, producing opaque black boxes around sprites. Removing this offset restored transparency across all 544 GIF assets.
(3) Dynamic Resolution Detection
Six modules in the corpus use non-standard resolutions: WaiWai Cat runs at 176×220, Doodle Jump at 240×352, and both editions of My Own Swordsman, Three Kingdoms and Three Kingdoms! at 240×320. These games do not query host display metrics; they write directly to their internal frame widths. The emulator captures full-screen clear (VMFillRectEx) and clipping boundaries (SetClip) during the first 4 frames to dynamically adjust framebuffer dimensions.
6. Virtual File System (VFS) and Persistence Pitfalls
- ASCII Mode Strings: Path strings use UCS-2, but the
modeparameter uses single-byte ASCII ("rb","w+"). Single-character modes happen to work either way ("r"is72 00in both), but parsing"w+"as UCS-2 yields U+2B77, an unrecognized mode that falls back to read-only, so games like Gear of War could never save their configuration. - Error Return Codes: File open routines return
-1on failure, not0. Because0is a valid handle, returning0makes the guest believe the file exists, read zero bytes, and then parse the null pointer returned bymalloc(0). - Partial File Updates: GodWar updates its 163-byte
GodWarGameRecordsave by overwriting only the first 20 bytes (while loading reads 159). If the host’sStorage_Dateopens an existing save with truncating"wb", the file shrinks to 20 bytes and can no longer be loaded. Existing saves must be opened with non-truncating"r+b", overwriting only the first n bytes. Storage_DatePolarity: Disassembly confirms argument 4 isisLoad: passing 0 serializes memory to disk, while passing 1 reads data into memory.
7. Offline Authentication and SMS State Machines
CoolBar modules attempt online activation against wmix.kvbar.com at boot. Forcing BILLING_GetPayNumByAppId to return 0 (no billing configured) directs the internal state machine straight into the local offline execution branch, requiring zero binary patches.
For billing prompts triggered at the end of trial levels (for example the SMS purchase after WaiWai Cat’s demo), the emulator simulates the asynchronous SMS callback:
# Emulating asynchronous SMS dispatch callback
# Calling back with 0 means "sent successfully"; the module then sets its paid flag and continues
self.defer(callback_ptr, (0,), "smsResult")Content originally unlocked by paid SMS stays playable with no network at all.
VI. Architectural Evolution: Reference Python to Production Rust
1. Eliminating Pipeline Bottlenecks
Profiling the initial Python prototype revealed that 99.7% of frame time was consumed converting RGB565 to RGB888 and encoding PNGs via PIL, while Unicorn CPU simulation accounted for under 0.3%.
Early Prototype Pipeline:
Unicorn Memory ──► Python RGB888 Conversion (47.5 ms) ──► PIL PNG Encoding (53.2 ms) ──► UI
Frame Time: ~100 ms (approx. 10 FPS)
Optimized Architecture:
Unicorn Memory ──► One mem_read of raw RGB565 (0.01 ms) ──► Frontend converts and displays
Core-side display cost: from ~100 ms down to negligibleThe core no longer encodes images at all: it hands the raw RGB565 frame over the C ABI, and each frontend (SwiftUI, Qt, Android, browser) does its own color conversion; the macOS app additionally offers optional Scale2x/Scale3x pixel upscaling.
2. Native Thumb Stubs for High-Frequency APIs
Profiling identified that VMGetCurrMainScreenImage (which returns the framebuffer pointer) is called from a few hundred to over 1,500 times per frame in GodWar depending on the scene, at peak accounting for three quarters of all host exits.
To eliminate this overhead, the runtime generates native Thumb stubs directly in unhooked memory:
ldr r0, [pc, #0] ; Load constant literal stored below
bx lr ; Return immediately to guest
.word <framebuffer> ; value that never changes at runtimeUnicorn executes these routines internally without exiting to Python or Rust; this alone removes roughly three quarters of GodWar’s host round trips per frame.
3. Dual-Track Differential Verification
To ensure that the Rust implementation (rust/emucore) introduced no regressions, the project built an automated differential testing pipeline that compares both implementations against recorded per-frame baselines:
# tools/baseline.py: three fingerprints recorded per frame (excerpt)
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. framebuffer CRC32: "drew it wrong"
zlib.crc32(names.encode()) & 0xFFFFFFFF, # 2. CRC32 of call-name sequence: "took a wrong path"
len(rt.mach.call_log))) # 3. number of host calls this frame- Pass Criterion: Framebuffer CRC32, call-name-sequence CRC32 and call count all match for 300 consecutive frames (the call fingerprint hashes names only, not arguments, because heap addresses may legitimately differ between implementations);
- Result: Verified with
tools/rsdiff.sh, the Rust core matches the recorded baselines (generated by the Python reference implementation) frame for frame on all 35/35 modules.
VII. Multi-Platform Deployment and Tooling
1. Cross-Platform Runtime Layout
┌────────────────────────────────────────────────────────────────────────┐
│ nieche-core (Rust Core) │
└──────────────────────────────────┬─────────────────────────────────────┘
│
┌────────────────────────────┼────────────────────────────┐
▼ (Subprocess Pipe IPC) ▼ (Chaquopy + ctypes) ▼ (Qt 6)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ macOS (SwiftUI) │ │ Android (Kotlin) │ │ Windows (Qt 6) │
│ NiecheEmu.app │ │ NiecheEmu.apk │ │ NiecheEmu.exe │
│ - Isolated Engine │ │ - Touch Mapping │ │ - Emulation Thread │
│ - Remote Catalogs │ │ - Phone Keypad │ │ - Crash Logging │
│ - Scale2x/3x │ │ - Memory Editor │ │ │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘- macOS: Built with SwiftUI. The emulation engine runs in an isolated worker process communicating via binary stdio pipes, keeping UI threads responsive. Supports remote package catalogs via
index.json. - Android: A Kotlin shell embeds a thin Python glue layer via Chaquopy, which calls the cross-compiled
libnieche.sothrough ctypes. An on-screen feature-phone keypad sits below the screen, and a memory editor is built in. Landscape games are played by turning the phone sideways (screen rotation is not supported yet). - Windows: The released app is built with Qt 6 in CI; the repository also contains an experimental native Win32 frontend (
rust/emuwin). - Web:
tools/run_web.pystarts a local HTTP server; the browser long-polls for frames and POSTs input. It is mainly a development tool.
2. 12×12 Dot-Matrix Chinese Typography
The handset’s original font sits in a private container inside the firmware’s language pack and has not been recovered yet. So that story text still displays, the emulator ships a replacement font, font12.cbef, whose encoding, indexing and metrics match the original (all 8,836 GB2312 code points; 12×12 for CJK, 6×12 half-width for ASCII):
- The first version used
tools/fontgen/mkfont.swiftto shrink system fonts to 12 px via CoreText and binarize them, but strokes ran together and looked blurry; - It now uses the open-source Fusion Pixel 12px bitmap font (OFL), converted with
tools/fontgen/bdf2cbef.py. Of the 176 characters Fusion Pixel lacks, 31 full-width math symbols were hand-drawn and 145 rare characters were taken from GNU Unifont and reduced to 12×12; VMGetFontWidth(isFullWidth)reports half- or full-width advances strictly according to its argument; ignoring the argument makes all CJK characters pile on top of each other, which looks like a broken font.
3. Memory Cheat Engine
The core provides a memory search interface: search the module’s RW data segment and heap for a value, filter again after it changes, then edit or lock the few remaining candidates (locked values are rewritten every frame). The Android memory editor also has game-specific options: in Three Kingdoms: Red Cliff (三国志·赤壁传) you can set officers’ levels directly and lock weapon durability, and in Lost in Island (孤岛) you can edit survival points directly.
VIII. Full Corpus Regression and Status
The regression script python3 tools/batch.py confirms that all 35 modules reach their main loops. The table below lists 15 of them. The Non-Black Pixels column is the number of non-black pixels after the Rust core runs 300 frames with no input (the denominator is the module’s screen size; at that point most games sit on a title or menu screen and GodWar on its opening text page, so the number only shows that the screen renders). Status was confirmed by playing into actual gameplay by hand.
| Module | Non-Black Pixels (300 frames, no input) | Status | Key Engineering Milestone |
|---|---|---|---|
| 众神之战.CBE (GodWar) | 1,389 / 96,000 | Fully Playable | Touch navigation, spin-wait escape, stream cache fix, partial save updates |
| 魔塔.CBE (Magic Tower) | 96,000 / 96,000 | Fully Playable | Dot-matrix narrative text layout, 12-key bitmask keypad mapping |
| 战争机器.CBE (Gear of War) | 96,000 / 96,000 | Fully Playable | Multi-package container (sub-packages loaded by name), single-byte ASCII "w+" mode |
| 皇牌空战.CBE (Ace Air Combat) | 93,407 / 96,000 | Fully Playable | Fixed allocations of size garbage caused by guessing one- vs. two-argument calls from stale registers |
| 武林外传(新品).CBE (My Own Swordsman, New Edition) | 47,103 / 76,800 | Fully Playable | Reconstructed 35 legacy syscalls, Syscall 2001 writebacks, SMS address arithmetic |
| 武林外传V10.CBE (My Own Swordsman V10) | 47,205 / 76,800 | Fully Playable | 240×320 dynamic resolution adoption, legacy game-library table layout |
| 歪歪猫发条城历险记.CBE (WaiWai Cat) | 23,222 / 38,720 | Fully Playable | 176×220 QCIF adaptation, empty placeholder platform files, key events via logic callback, async SMS callback |
| 涂鸦跳跃.CBE (Doodle Jump) | 84,480 / 84,480 | Fully Playable | 240×352 resolution recognition, key and touch input |
| 恶魔城.CBE (Demon Castle) | 96,000 / 96,000 | Fully Playable | Multi-package decompression, layered tilemap Blit blending |
| 僵尸先生.CBE (Mr. Zombie) | 95,760 / 96,000 | Fully Playable | Sub-package objects, GBK resource names, touch events, deferred screen switching |
| 三国志!.CBE (Three Kingdoms!) | 71,019 / 76,800 | Fully Playable | Launcher and embedded game separated, live r9 preservation, memory pool hooks |
| 极品飞车2012.CBE (racing game; its title screen reads The Need for Speed 2012) | 96,000 / 96,000 | Fully Playable | Pseudo-3D road rendering, high-frequency framebuffer updates |
| 愤怒的小鸟.CBE (Bird’s Anger) | 96,000 / 96,000 | Fully Playable | Landscape game in a portrait buffer, 270° rotation with inverse touch transform |
| 捕鱼猎人.CBE (Fish Hunter) | 96,000 / 96,000 | Fully Playable | Type-1 GIF transparency decoding |
| AppStore.CBE | 88,820 / 96,000 | Navigable | Address 0 zero-fill defense, -1 on failed file open |
Known Limitations
- Network-Dependent Titles: Pure-online MMORPG modules (such as Jianghu OL) finish unpacking local resources and initializing the UI, then stop at “Fetching version info: contacting server”. The servers are long gone, so they cannot be played without a server emulator;
- Original Font: The current 12×12 font is a replacement (Fusion Pixel 12px) with matching encoding and metrics; reverse-engineering the firmware’s private font container is not finished.
IX. Conclusion
By combining automated mining of the firmware’s DWARF symbols, a hardware-level ARM trampoline that resolves the two-SDK conflict, reconstruction of the legacy numeric system calls from the binaries, and dual-track differential testing from Python to Rust, nieche-emu achieves high-fidelity emulation of the CoolBar .CBE application ecosystem without any official technical documentation.
The complete toolchain and platform frontends are open source, including DWARF extractors, resource unpackers, disassembly scripts, and multi-platform clients, all available under the nieche-cbe-emu GitHub organization.