docs/3ds-emulation-strategy-spike.md
Spike Date: 2026-03-25 Related Issue: #2840 Status: Investigation Complete — Awaiting Decision
Provenance ships two separate 3DS core wrappers:
| Wrapper | Path | Submodule URL | Submodule path |
|---|---|---|---|
| PVEmuThree | Cores/emuThree/ | rf2222222/emuThreeDS | Cores/emuThree/emuthree |
| PVAzahar | Cores/Citra/ (dir name mismatch) | Provenance-Emu/emuThreeDS | Cores/Citra/azahar |
Both cores share an identical "override files" build pattern: local files placed in
Cores/emuThree/PVEmuThreeCore/emuThree/ or Cores/Citra/PVAzaharCore/azahar/ shadow
identically-named files from the upstream submodule at compile time via Xcode's header search
path and source file ordering.
Key architectural note: Cores/Citra/azahar does NOT point to AzaharEmulator/azahar
(the actual Azahar upstream). It points to Provenance-Emu/emuThreeDS — a Provenance-owned
fork of the same emuThreeDS codebase as PVEmuThree, with additional Azahar-derived patches applied manually.
Both cores override, among others, the following key files relative to their respective submodule root:
| File | What It Does | Notes |
|---|---|---|
CitraWrapper.mm / CitraWrapper.h | ObjC wrapper for the core — startVM, stopVM, save/load, input dispatch | Primary integration layer; both cores have // Local Changes: Add Save/Load/Cheat comment |
InputBridge.mm / InputBridge.h | Connects iOS GCController to Citra's input system | |
InputFactory.mm / InputFactory.h | Factory for creating Citra input devices | |
emu_window.cpp / emu_window.h | Base Apple/Metal window class (EmuWindow_Apple) | Defines PresentingState enum, CA::MetalLayer lifetime |
emu_window_vk.mm / emu_window_vk.h | Vulkan/Metal swap-chain surface management | Key difference between cores — see §1.3 |
MultiplayerManager.mm / MultiplayerManager.h | Stub multiplayer (no LAN on iOS) | |
Camera/CameraFactory.mm / .h | AVFoundation camera backend | |
Camera/CameraInterface.mm / .h | Camera interface protocol impl |
| File | What It Does |
|---|---|
audio_core/coreaudio_sink.cpp / .h | iOS CoreAudio output sink (replaces SDL2/OpenAL) |
audio_core/coreaudio_input.cpp / .h | Microphone input via CoreAudio |
audio_core/interpolate.cpp / .h | Audio interpolation tweaks |
audio_core/null_sink.h | Null audio backend |
audio_core/openal_sink.cpp / .h | OpenAL fallback sink |
audio_core/openal_input.cpp / .h | OpenAL microphone input |
audio_core/sdl2_sink.cpp / .h | SDL2 sink stub (disabled on iOS) |
| File | What It Does |
|---|---|
common/settings.h | iOS-specific GraphicsAPI enum variants, MobilePortrait/MobileLandscape layout options, iOS button input device ownership (std::unique_ptr members in Values struct) |
common/settings.cpp | Settings initialization for iOS context |
core/savestate.cpp / .h | Save state hooks |
| File | What It Does | Override Comment |
|---|---|---|
renderer_vulkan.cpp | Delays vmaDestroyImage — avoids UAF crash | // Local Changes: delay vmaDestroyImage |
vk_pipeline_cache.cpp | Pipeline cache tuning for Metal/MoltenVK | |
vk_platform.cpp | Vulkan platform abstraction for Metal | |
vk_rasterizer.cpp | Rasterizer tweaks | |
vk_render_manager.cpp | Render manager adjustments | |
vk_swapchain.cpp | Metal/MoltenVK swap chain management | |
vk_texture_runtime.cpp | Texture allocation and runtime tweaks for Metal | |
shader_interpreter.cpp | Shader interpreter patches |
| File | Notes |
|---|---|
applet_manager.cpp | Applet (software keyboard, Mii selector) — iOS UI |
mii_selector.cpp | Mii selector applet |
hid.cpp, extra_hid.cpp | HID / NFC input patches |
bench*.cpp, regtest*.cpp, validat*.cpp, datatest.cpp | Test/benchmark stubs; not upstream files |
rsa.cpp, ccm.cpp | Crypto patches |
emu_window_vk.mm — The GPU Regression Root CauseThe two emu_window_vk.mm files are structurally similar but PVAzahar has a much more complex
implementation that is the most likely source of the GPU black screen regression:
PVEmuThree (Cores/emuThree/PVEmuThreeCore/emuThree/emu_window_vk.mm):
CreateWindowSurface — just sets window_info.render_surface = host_windowTryPresenting calls VideoCore::g_renderer->TryPresent(0) (old-style global pointer)OrientationChanged always calls OnSurfaceChanged + OnFramebufferSizeChangedPVAzahar (Cores/Citra/PVAzaharCore/azahar/emu_window_vk.mm):
CreateWindowSurface is more defensive: null-checks host_window, logs warning on failure,
sets window_info.render_surface_scale from [UIScreen mainScreen] nativeScale, computes
window_width/height from drawable size divided by scaleTryPresenting uses Core::System::GetInstance().GPU().Renderer().TryPresent(0) — the newer
Azahar API style (separate GPU object), wrapped in try/catch with frame counter loggingOrientationChanged only calls OnSurfaceChanged if host_window != surface (deduplication)#include "video_core/gpu.h" and #include "core/core.h" (Azahar-era headers)Likely regression cause: PVAzahar's TryPresenting uses system.GPU().Renderer() which
requires the GPU object to be fully initialized. If the GPU hasn't been initialized yet when
TryPresenting is first called (the Initial → Running transition), .GPU() may return a
reference to an uninitialized object, leading to the flickering first frame then black screen.
The frame counter logging added in PVAzahar also suggests someone was investigating this.
Additionally, PVAzahar computes window_width/height as drawableSize / nativeScale, which
converts the Metal layer's pixel-backed drawableSize into logical points. It's unclear whether
window_width/height and UpdateCurrentFramebufferLayout(...) are intended to use pixels or
points, so there may be a units mismatch causing incorrect framebuffer layout dimensions.
_frameInterval = 60 (correct for 3DS)_frameInterval = 120 (double — may affect timing)rightEyeDisableOption (performance option to skip rendering the right eye)0 (auto), PVAzahar is 100 (fixed 100%)enableAsyncShader defaults to false; PVEmuThree defaults to trueCitraWrapper.mm includes a PVPostOSD helper for posting OSD notifications via
NSNotificationCenter — not present in PVEmuThree (added in PR #3151)Cores/emuThree/emuthree → https://github.com/rf2222222/emuThreeDS.git (original iOS fork)
Cores/Citra/azahar → https://github.com/Provenance-Emu/emuThreeDS.git (Provenance fork)
The "Azahar" core does NOT track the actual AzaharEmulator/azahar upstream.
It tracks Provenance-Emu/emuThreeDS, which appears to be a fork of rf2222222/emuThreeDS with
additional patches applied, some inspired by Azahar. This naming is deeply confusing.
| Project | Relationship | iOS Status | Notes |
|---|---|---|---|
| Citra | Original | ⛔ Archived (2024 Nintendo C&D) | All forks derived from this |
| Lime3DS | Citra fork | ✅ Active iOS support | Continued development post-C&D; powers Folium/Cytrus |
| Azahar | Lime3DS fork | ❓ Unknown iOS status | More features; unclear iOS packaging |
| emuThreeDS | Citra fork (iOS-optimized) | ✅ iOS-specific | Our current submodule; sporadic updates; iOS perf hacks |
| App | Backend | Build System | Notes |
|---|---|---|---|
| Folium | Cytrus (Lime3DS) | CMake + XCFramework | Active; multi-system; App Store; best iOS integration reference |
| Cytrus | Lime3DS | CMake + SPM-compatible | Standalone iOS framework; the model to study |
| Delta | N/A | — | No 3DS support |
Lime3DS + Cytrus/Folium is the most mature iOS path. Cytrus ships as an XCFramework built with CMake, already in the App Store via Folium. This is the closest analog to what Provenance needs.
Azahar is actively developed but has no known clean iOS packaging. Adopting it directly would require significant investigation.
emuThreeDS (rf2222222) has valuable ARM64 SIMD, JIT/JITless, and Metal patches but tracks an older Citra base. The iOS-specific hacks are the key value — the upstream C++ is aging.
JIT vs JITless remains critical. Cytrus/Folium handle both paths. Any migration must preserve this.
Priority: HIGH — This should be done regardless of long-term strategy.
Root cause hypothesis: TryPresenting in emu_window_vk.mm calls system.GPU().Renderer()
before the GPU is fully initialized. The frame counter logs confirm someone was debugging this.
Proposed fix:
system.GPU().Renderer().TryPresent(0)TryPresenting to the simpler VideoCore::g_renderer->TryPresent(0) pattern
from PVEmuThree to eliminate the GPU() initialization race_frameInterval from 120 to 60 in PVAzaharCoreBridge.mmwindow_width/height calculation (drawableSize / scale) may produce incorrect values —
compare with PVEmuThree's approach and validatePriority: MEDIUM — After GPU fix.
Given that PVAzahar is built on the same Provenance-Emu/emuThreeDS fork as PVEmuThree (not actual
Azahar upstream), there is limited benefit to maintaining both wrappers. PVEmuThree is the proven,
user-preferred core.
Recommendation: Treat PVAzahar as an experimental variant, PVEmuThree as canonical.
Tasks:
rightEyeDisableOption from PVAzahar into PVEmuThreePVPostOSD) into PVEmuThreeemu_window_vk.mm into PVEmuThreePriority: LOW — Research spike; 4–8 weeks of work.
The highest-leverage long-term move is migrating PVEmuThree's submodule from rf2222222/emuThreeDS
to a fork of Lime3DS (or adopting Cytrus as a prebuilt XCFramework).
Rationale:
Migration path:
Provenance-Emu/Lime3DS fork for Provenance-specific patchessettings.h mobile layout additions (MobilePortrait/MobileLandscape) must be preservedThese files contain iOS-specific code that has no upstream equivalent and must be ported to any new upstream base:
| File | Why Critical |
|---|---|
audio_core/coreaudio_sink.cpp | Apple CoreAudio output — iOS/macOS only |
audio_core/coreaudio_input.cpp | Microphone via CoreAudio |
Camera/CameraFactory.mm | AVFoundation camera |
emu_window.cpp / emu_window.h | Metal/iOS window base class |
emu_window_vk.mm | Metal surface + Vulkan/MoltenVK integration |
common/settings.h | MobilePortrait/MobileLandscape layout options; iOS input ownership |
renderer_vulkan.cpp | vmaDestroyImage delay fix (crash prevention) |
CitraWrapper.mm | Entire iOS integration layer |
InputBridge.mm / InputFactory.mm | GCController integration |
emu_window_vk.mm per §3.1