Notes

Writing a Mobile Platform Emulator

A collection of patterns that came up repeatedly while building WIE, an emulator for legacy Korean mobile platforms. None of this is theory โ€” every pattern described here ended up in the codebase because the alternatives were worse. The targets these notes have in mind are old feature-phone runtimes, but most of the principles generalize to other "emulate a software platform that wraps real hardware" situations.

Decide What You're Actually Emulating

An emulator for "an old mobile platform" can mean several different things, and the choice up front changes the entire shape of the project. The four main options:

  • Pure hardware emulation โ€” emulate the CPU, memory map, peripherals, and let the original firmware run on top. Maximum fidelity, maximum work, and you need ROM dumps of the firmware.
  • HLE (high-level emulation) of the runtime โ€” emulate the application runtime APIs directly in host code, skipping the firmware entirely. Far less code, but you have to know what the runtime actually does.
  • Hybrid โ€” emulate the CPU and memory map enough to run application binaries, but replace the platform's runtime services with host implementations. This is what WIE does for KTF and LG Telecom apps.
  • Pure VM emulation โ€” for bytecode-only platforms (J2ME, SKT WIPI), no CPU emulation needed at all. Just implement the VM and the standard library.

The right choice depends on what's in the app archive. If apps ship as bytecode for a known VM, there's no reason to drag in a CPU emulator. If apps ship as AOT-compiled native code (KTF's client.bin, for example), you need instruction-level execution for at least the parts of the code that were compiled. Decide this first, because it determines whether you're writing a VM or a CPU emulator or both.

Push Everything OS-Specific Behind a Trait

The single most useful structural decision in WIE was making the host environment an explicit dependency. Everything that touches the outside world โ€” the window, audio output, filesystem, clock, standard I/O โ€” goes through one trait. The emulator core takes that trait as input and never calls into the operating system directly.

The immediate benefit is that the same emulator core can be driven by a command-line host during development and by a web frontend in production without any conditional compilation in between. The deeper benefit is that the trait forces you to enumerate exactly what host capabilities the emulator needs. If you can't write down the host interface as a finite list of methods, the emulator is probably reaching into the host in ways that will break the first time you change hosts.

For WebAssembly targets specifically, this also prevents an entire class of build-time surprises. no_std in the core crates plus an explicit host trait means the emulator literally cannot accidentally depend on filesystem APIs or native threading. Whatever the host provides is what's available, full stop.

Use Async as the Threading Model

Old mobile platforms almost always have a threading model. WIPI apps spawn threads, J2ME apps spawn threads, native ARM code on KTF runs in its own thread context, and the original platform supplied an OS-level scheduler to make this work. Reproducing that scheduler on every host environment โ€” especially in a browser, where OS threads aren't directly accessible โ€” is annoying.

The pattern that worked: model each emulated thread as a cooperative async task. The emulator's executor takes the role of the original platform's scheduler. Each task owns its own per-thread state (register file, stack, JVM frame stack, whatever the platform needs) and yields at points where the original code would have yielded โ€” typically blocking system calls, sleeps, and audio waits.

This collapses what would otherwise be three different threading implementations (native threads, browser-side Web Workers, and CLI tooling) into one. The emulator only knows about tasks; the host decides how those tasks actually get driven. On the browser side this also removes the need for any thread synchronization primitives, because the tasks are single-threaded by construction.

Callback Trampolines for Native Bridges

When you're running emulated native code that needs to call host functions โ€” say, a WIPI C API call that has to draw to a host-side framebuffer โ€” the standard solution is callback trampolines. The pattern is:

  • The host registers a function with the emulator and receives a synthetic address in return. The address is not a real instruction pointer; it's a sentinel value that the emulator recognizes.
  • The host writes that synthetic address into emulated memory wherever the application's code expects a function pointer to a platform routine.
  • When emulated execution reaches the synthetic address, the CPU emulator suspends normal instruction dispatch and invokes the registered host function instead. The host function can read and write emulated state directly, then return.

This is a small mechanism that ends up carrying an enormous amount of weight. In WIE, every WIPI C API call goes through it. Every Java bridge trampoline does too. Native import resolution, allocation helpers, exception handlers, class loading โ€” all of it. The emulator core only has to understand "execution reached a trampoline address; suspend and call out." Everything platform-specific lives on the host side.

The discipline this enforces is worth more than the mechanism itself. Because every host-side service is a registered function, you can grep your codebase for every place the emulated code talks to the outside world. There's no hidden coupling, no "this function happens to be called from native code sometimes," no surprise side effects.

Treat Binary Formats as a First-Class Concern

A lot of the work in emulating old platforms isn't execution โ€” it's parsing. Application archives come in undocumented formats. Native binaries embed platform-specific metadata structures that look like C structs but with packing and alignment that nobody wrote down. The "Java classes" in a KTF binary aren't .class files at all but proprietary descriptors that point to AOT- compiled ARM code.

Treat the binary format definitions as a proper first-class artifact of the project, not as inline parsing scattered across the codebase. A separate crate that holds the reverse-engineered struct definitions, with names and field offsets and known-good test cases, becomes the source of truth for "what does this byte mean." When you later discover that field 0x4C is actually a flag rather than a pointer, you change it in one place and the rest of the emulator follows.

This is also the artifact that survives the project. Code rots, but a well-documented binary format definition file is useful indefinitely โ€” including to future preservation efforts that don't even use the same emulator.

Don't Force All Platforms Through One Abstraction

The instinct when you're supporting multiple platforms is to find the common abstraction and route everything through it. Resist this for the platform-specific layers. The individual platforms genuinely are different โ€” they have different boot sequences, different binary formats, different native integration models โ€” and trying to hide those differences behind a single interface either leaks the differences anyway or imposes constraints that don't match how the platforms actually worked.

Share what's actually shared (the JVM, the ARM core, the audio path, the host abstraction) and let each platform crate own its own boot sequence and native integration. The total amount of code is roughly the same either way, but the platform-specific code is much easier to read and modify when it isn't draped over a generic abstraction that wasn't designed for it.

Expect Audio to Be Its Own Project

On the platforms WIE targets, the audio format is SMAF (Yamaha's MMF). It's not MIDI, it's not PCM, it's not anything an off-the-shelf library decodes. The format has its own event timeline, its own instrument definitions, and several variant tracks that need to be unified at playback time.

Building a SMAF/MMF decoder ended up being its own separate project, used by WIE but independent of it. This generalizes: whatever the audio format on your target platform is, expect that supporting it well will be its own sub-project. Audio is never just "decode a buffer and play it"; there are timing constraints, looping behavior, hardware-specific quirks, and platform-specific event semantics that all have to be respected if applications are going to behave the way they did on hardware.

Reverse Engineering Is Most of the Work

The implementation of the emulator is, in the end, not the hard part. The hard part is figuring out what to implement: what each platform API actually does, what undocumented behaviors applications rely on, what the binary structures actually contain, what side effects matter and what side effects don't. That work is reverse engineering โ€” disassembling binaries, tracing execution against real hardware logs where you have them, and guessing at intent from observed behavior.

Budget for this honestly. The visible output of an emulator project is the running application, but most of the hours are spent in disassemblers and hex editors and notebooks, well before anything visible to a user shows up. The upside is that the reverse-engineering output โ€” binary format documentation, API behavior notes, structure layouts โ€” is durable and reusable in ways that the emulator code itself isn't.

This page is a best-effort write-up โ€” some details may be inaccurate or out of date. If you spot an error, please flag it via the email on the home page.

References

The patterns above are extracted from WIE. For specifics โ€” how the layering actually looks in code, what the RustJava JVM provides, what the KTF binary format contains โ€” see the project write-ups.