iOS / iPadOS host (rustynes-ios + the SwiftUI app)¶
The iOS / iPadOS application is a thin native SwiftUI shell over the
byte-identical Rust core, introduced by v1.9.0 "Sunrise" (the foundation
slice of the v1.9.0 -> v1.9.9 TestFlight train; see
to-dos/plans/v1.9.x-ios-train-plan.md). It is the Apple analogue of the Android
host: one core-binding layer (rustynes-mobile), two platform shims
(rustynes-android, rustynes-ios). This doc is the implementation spec for
the iOS side; it is the spec, not history — update it in the same PR as a
code change. The authoritative per-release status lives in docs/STATUS.md.
Architecture — the determinism boundary holds¶
┌──────────────────────── ios/ (SwiftUI app, native) ────────────────────────┐
│ RustyNESApp · ContentView · MetalGameView · ROMLibrary · SettingsView │
│ TouchControlsOverlay · GameControllerManager · AudioSession (AVAudioSession)│
│ │ drives the core via … │ hot glue via the C ABI … │
│ ▼ ▼ │
│ NesController (UniFFI-generated Swift) rustynes_ios.h (extern "C") │
└────────│─────────────────────────────────────────│──────────────────────────┘
│ rustynes-mobile (UniFFI bridge) │ rustynes-ios (this crate)
▼ ▼
rustynes-core (no_std + alloc, byte-identical) wgpu->Metal · cpal CoreAudio
- The chip stack + the emitted-frame/sample contract are never touched. All
iOS work is platform shell. The desktop / native /
no_std/ wasm builds stay byte-identical, AccuracyCoin 139/141 (the two newest upstream PPU tests are known gaps) holds on host CI, and the determinism contract (same seed+ROM+input => bit-identical framebuffer+audio on ARM) is preserved — so desktop <-> Android <-> iOS save portability (and, once it lands, netplay cross-play) stays valid. The iOS app consumes the sameNessnapshot/output the oracle validates. - The typed control surface is UniFFI, not hand-written.
rustynes-mobile's#[uniffi::export]surface (NesController:runFrame() -> Data,drainAudio() -> [Float],setButtons(port, mask),saveState/loadState,reset/powerCycle, region/mapper/identity queries, plus the movie / HD-pack / RA / netplay methods) is generated for Swift exactly as it is for Kotlin. The SwiftUI app drives the emulator through that generatedNesControllerclass. rustynes-iosadds only the hot glue UniFFI cannot express, reached over a small hand-written C ABI (crates/rustynes-ios/include/rustynes_ios.h): the Metal surface lifecycle (Workstream B) and the CoreAudio sink (Workstream C). It is the iOS analogue of the Androidjni_glue.
rustynes-ios crate (crates/rustynes-ios/)¶
crate-type = ["lib", "staticlib"]. The staticlib is the per-arch .a the app
links; a Rust staticlib is self-contained (it bundles every rlib dependency,
so librustynes_ios.a carries rustynes-mobile + rustynes-core too — the
xcframework links one archive). The lib output keeps the host build
(cargo build --workspace) green so CI lints the crate: every iOS-specific
symbol is #[cfg(target_os = "ios")], so on a Linux/macOS host this compiles
to a near-empty shell with no Metal / CoreAudio code. The real archive is built
against aarch64-apple-ios / aarch64-apple-ios-sim.
| File | Role |
|---|---|
src/lib.rs |
Host-safe core_version() + the #[cfg(target_os = "ios")] module gate. |
src/gfx_metal.rs |
Workstream B: MetalGfx — wgpu->Metal blit from a CAMetalLayer, 8:7-PAR letterbox, the shared CRT / NTSC / Bisqwit pipelines. Byte-for-byte the Android gfx.rs minus the window handle. |
src/audio.rs |
Workstream C: AudioSink — a cpal CoreAudio output stream fed by a lock-free SPSC ring (mono core samples -> device channels; silence on underrun). |
src/ffi.rs |
The extern "C" seam (gfx init/resize/render/set_filter/set_index_frame/destroy + audio new/push/sample_rate/pause/resume/destroy), opaque *mut handles. |
include/rustynes_ios.h |
The C header the Swift bridging header #includes and the xcframework packages. |
unsafe is confined to this crate + the FFI glue, each site with a // SAFETY:
note (the same exemption rustynes-cheevos / rustynes-frontend /
rustynes-android take); #![allow(unsafe_code)] keeps the workspace pedantic /
nursery clippy gates otherwise on.
Metal surface — the one platform difference from Android¶
wgpu 29's public SurfaceTargetUnsafe has no CoreAnimationLayer variant.
The shim builds a surface from the UIView pointer (the SwiftUI MTKView, a
UIView whose backing layer is a CAMetalLayer) via a raw-window-handle 0.6
UiKit handle:
let raw_window = RawWindowHandle::UiKit(UiKitWindowHandle::new(view_nn.cast()));
let target = wgpu::SurfaceTargetUnsafe::RawHandle {
raw_display_handle: Some(RawDisplayHandle::UiKit(UiKitDisplayHandle::new())),
raw_window_handle: raw_window,
};
let surface = unsafe { instance.create_surface_unsafe(target) }?;
wgpu-hal reads view.layer and drives Metal — no objc2 dependency is
needed. Everything downstream (device/queue, the three shader pipelines, the
CurrentSurfaceTexture acquire, Lost/Outdated reconfigure-and-skip) is
identical to the Android renderer and the desktop frontend; the WGSL is the shared
rustynes-gfx-shaders crate (single source of truth across all three platforms).
Audio — cpal owns the stream, Swift owns the session¶
AudioSink opens a cpal CoreAudio output stream (cpal 0.18 builds for
aarch64-apple-ios out of the box) fed by a lock-free SPSC ring; the cpal
callback drains the ring, fanning each mono APU sample out to the device's
channels and emitting silence on underrun. AVAudioSession (category
.playback, activation, interruption / route-change / silent-switch handling) is
configured Swift-side; on an interruption / scene-background the app calls
rustynes_ios_audio_pause and pauses the emulator, so there is no special
teardown. (A full Hermite DRC resampler, as on the desktop resampler.rs, is a
documented v1.9.x follow-up; the foundation ships the lock-free ring.) The ring is
a frontend resampler stage — the core samples are untouched, so the audio
oracle and cross-device save portability are preserved.
The SwiftUI app (ios/)¶
A checked-in XcodeGen spec (ios/project.yml) generates the .xcodeproj
(more maintainable + reviewable than a hand-authored .pbxproj; CI runs
xcodegen generate before xcodebuild). The app target links the
RustyNESFFI.xcframework as "Do Not Embed" (a static xcframework is linked,
not embedded/signed) plus the system frameworks Metal / MetalKit / GameController /
AVFoundation / UIKit, and includes the generated Generated/RustyNESCore.swift
(the UniFFI Swift bindings) and the RustyNES-Bridging-Header.h (#include
"rustynes_ios.h").
- Game surface (
MetalGameView): aUIViewRepresentablehosting theMTKView. ACADisplayLink(preferredFrameRateRange60-120 for ProMotion; Info.plistCADisableMinimumFrameDurationOnPhone = true) drives the loop: each ticknes.runFrame()->rustynes_ios_gfx_render(...)andnes.drainAudio()->rustynes_ios_audio_push(...). The drawable size comes from the view; a bounds/scale change callsrustynes_ios_gfx_resize. The core emulates at the console rate (60.0988 Hz); the audio sink absorbs the display beat. - Input: the on-screen pad and a
GameControllerManager(GCControllerdiscovery) both converge on the samesetButtons(port, mask)late-latch as desktop / wasm -> TAS / netplay identical. (v1.9.2) the pad is aUIView-backed multi-touch responder (touchesBegan/Moved/Endedover all active touches, replacing the v1.9.0 singleDragGestureso simultaneous distant presses register), NES-001-styled and sized from the available geometry (iPhone / iPad / split-view / Stage Manager); the GameController path handles P1-P4 with a persisted remap model; optional Core Haptics (off by default) gives light press feedback. All of it still funnels through the one per-port bitmask, so the determinism contract is untouched. - ROM import (
ROMLibrary):UIDocumentPicker/.fileImporter/ share-sheet, security-scoped, copied intoApplication Support/RustyNES/roms/keyed by SHA-256 (the desktop save-identity scheme). Never bundle commercial ROMs. - Storage + lifecycle:
.rnssave-states + SRAM in the sandbox (the format is platform-independent -> cross-device save portability); SwiftUIScenePhasepauses the loop / audio and drops the drawable on background, rebuilding on foreground.PrivacyInfo.xcprivacydeclares no data collected.
Creator / power tools (v1.9.9 "Workshop")¶
The final iOS TestFlight release before the v2.0.0 core rewrite adds a
creator/power-tools set. Every piece is additive / opt-in and forwards to
existing core APIs through additive rustynes-mobile bridge functions, so with
the tools unused the app behaves as v1.9.8 and AccuracyCoin holds 139/141 (the
determinism contract is untouched; the two newest upstream PPU tests are known gaps).
- Cheats (
CheatsView, in-game pill menu): add / remove / clear / list Game Genie codes (the core's own cheat engine applies them live to PRG reads, exactly like the desktop) plus a raw-RAM editor that pokes / peeks a CPU-RAM byte ($0000-$1FFF) through the existingpoke_ram/ side-effect-freepeekpaths. No per-frame mutation is added, so an empty cheat set is byte-identical. - Read-only debugger inspector (
DebuggerView), gated OFF the App-Store build viaBuildChannel(the FOSS / TestFlight channel only, ADR 0027): a CPU register view (cpu_snapshot), a disassembly around the PC (rustynes-cpu'sdisassemble_atover a bounded byte window), and a CPU-RAM hex view (peek). All observational (never advances / mutates the core); a single "Step" advances exactly one frame while the inspector holds the emulator paused. Optional.sym/.mlb/.nlsymbol files annotate the disassembly (parsed host-side inSymbolMap.swift— the core exposes no symbol API and the mobile bridge does not depend on the frontend's parser). - Touch TAStudio piano-roll (
TAStudioView): a frame-by-frame P1 input table (8 button columns). "Play" injects the table one mask per frame through the existing bridge (setButtons+runFrame, deterministic); "Save .rnm" arms the core's recorder (movie_record_from_power_on) and replays the table so the captured input is written to a real native.rnmmovie. No movie-editing API is added to the core. - Foreign movie import (
MoviePanelView+UIDocumentPicker): FCEUX.fm2, BizHawk.bk2, Nestopia.fcm, Famtasia.fmv, and VirtuaNES.vmvare transcoded to a native.rnmfor the loaded game (the bridge calls the core'simport_fm2/import_bk2/import_fcm/import_fmv/import_vmv, serialising the result), then saved + played via the existing movie surface. Malformed files error gracefully (bounds-checked; the.bk2ZIP member extraction caps member sizes), never crash. - Host audio-depth DSP (Settings -> "Audio depth"): an output-only stereo
enrichment stage (5-band EQ, pan, Schroeder reverb, headphone crossfeed) ported
from the desktop frontend, applied in the CoreAudio callback after the core's
mono master is drained — never in the synthesis. It lives in the host-safe
crates/rustynes-ios/src/audio_dsp.rs(unit-tested on the host build) and is wired into the cpal sink (audio.rs) over a lock-free atomic config mailbox. A disabled / flat / centered config is a bit-exact passthrough, so the audio oracle and save portability are preserved.
Build pipeline (scripts/build-ios-xcframework.sh)¶
rustup target add the iOS targets -> cargo build --release -p rustynes-ios
per arch -> lipo the simulator arches -> generate the Swift bindings from the
device .a (cargo run -p rustynes-mobile --bin uniffi-bindgen -- generate
--library … --language swift; rename the modulemap to module.modulemap) ->
assemble the headers dir (rustynes_mobileFFI.h + rustynes_ios.h +
module.modulemap) -> xcodebuild -create-xcframework -> xcodegen generate.
CI (.github/workflows/ios.yml) runs this on macos-latest, gated to tag
pushes (v*) + manual dispatch because macOS minutes bill ~10x — the host
ci.yml remains the accuracy / determinism authority and is never gated on a
device toolchain. fastlane (match read-only signing + gym + pilot) uploads
to TestFlight via an App Store Connect API key. The upload is gated on the
signing secrets being present (a "Detect iOS signing secrets" step): until they
are provisioned, the xcframework build still runs (proving the iOS host compiles)
but the TestFlight upload is skipped with a notice (green) rather than failing
every release tag push at fastlane's app_store_connect_api_key step. (v1.9.1)
a schedule: cron (the 1st of every other month, ~60-day cadence) re-builds +
re-uploads so external testers don't lapse — TestFlight builds expire 90 days
after upload.
A dormant freemium gate (ios/RustyNES/Entitlements.swift, v1.9.1) is wired
app-wide but fully unlocked through the v1.9.x train; it is the present-but-inert
seam the v2.1.0 launch points at the shared rustynes-monetization crate (the full
StoreKit 2 / RevenueCat scaffolding lands at v1.9.8 "Horizon"). Entitlement state
never reaches the deterministic core.
App Store posture (§4.7) — feasible and precedented¶
Apple's April-2024 Guideline 4.7 permits retro-console emulators; Delta
(NES-capable) shipped and topped the App Store with user-provided ROMs, and
RustyNES is a pure interpreter so the no-JIT rule is a non-issue (the core is
cycle-stepped, mlua is interpreted, wgpu shaders are toolchain-compiled). NES
ROMs carry no encryption, so there is no DMCA anti-circumvention question
post-Nintendo v. Yuzu — but a ROM image is still a copyrighted work, so legality
rests on the user lawfully owning the ROM, which is exactly why the rule is
user-sourced-ROMs-only, no bundling, no download path, no in-app ROM links, a
clear in-app ownership notice. Full strategy + the distribution phasing
(TestFlight through v1.9.x; App Store + AltStore PAL deferred to v2.1.0, joint
with Android, after the v2.0.0 "Timebase" core rewrite) live in
docs/adr/0027-ios-distribution-and-app-store-compliance.md and
to-dos/plans/v2.0.x-mobile-finalization-plan.md.
v1.9.x → v2.0.x status + carryovers¶
v1.9.0 "Sunrise" landed the foundation: the rustynes-ios shim, the SwiftUI
app, the xcframework build + tag-gated macOS CI + fastlane, and the MVP feature
set (core + Metal video + CoreAudio + touch / controller + save-states / rewind /
run-ahead / TAS-playback). The v1.9.x train then shipped the deferred connectivity
and scripting: Lua scripting (v1.9.6), RetroAchievements (v1.9.6), and netplay
(v1.9.6/v1.9.7) are all live (the shared bridge already exposed them, so each
reduced to SwiftUI chrome), plus iCloud save-state sync (v1.9.7) and the
Google-Play-parity polish (v1.9.8). The current line is v1.9.9 "Workshop" —
the creator / power-tools release (read-only debugger inspector, raw-RAM editor,
Game Genie codes, foreign-movie import .fm2/.bk2/.fcm/.fmv/.vmv, the
TAStudio piano-roll, custom palettes, HD-packs, and the audio-depth controls). The
host gates (fmt / clippy -D warnings / rustdoc / no_std / wasm) stay green and
AccuracyCoin held 139/141 across the v1.9.x line (measured on the v2.0.1 oracle
re-sync, where the two newest upstream PPU tests were known gaps), because the crate
is a host shell off-device.
v2.0.5 "Landfall" — the iOS re-port onto Timebase. The v2.0.x "Harbor" train
re-ports the frozen v1.9.9 line onto the v2.0.0 "Timebase" core (the iOS analogue
of the Android v2.0.1 re-port), so the current line is now v2.0.5 and AccuracyCoin
is back to 141/141 (the shipped v2.0.3 default, unchanged by this host-only cut).
v2.0.5 surfaces + localizes (EN + ES) the pre-Timebase movie warning: loading a
pre-v2.0.0 .rnm still replays its input, but a non-blocking notice — drained via
EmulatorCore.drainWarnings() → NesController.drainWarningCodes(), mapping
HostWarning.preTimebaseMovie — tells the user byte-exact framebuffer / audio
reproduction is not guaranteed across the ADR-0028 epoch change (the iOS analogue of the
Android v2.0.4 warning; the notice uses a distinct channel from the error alert). The
iOS MARKETING_VERSION is realigned from the frozen v1.9.x default to 2.0.5. iOS
finalization continues v2.0.6 → v2.0.8 (feature parity → polish + the Xcode 26 /
iOS 26 SDK App Store submission floor → the iOS RC); full phasing in
to-dos/plans/v2.0.5-v2.0.8-ios-finalization-plan.md.
v2.0.6 "Parity" — feature parity + opt-in crash reporting. v2.0.6 closes the v1.9.9
iOS-applicable deferral for an opt-in crash-reporting surface (CrashReporter.swift,
the iOS analogue of the Android v1.8.8 CrashReporter): off by default, Settings →
Diagnostics installs an uncaught-NSException handler that writes local crash logs
(app version / device / OS / exception / call stack, newest 10 kept) the user can view +
copy in-app — nothing is uploaded, so the "Data Not Collected" label + the
PrivacyInfo.xcprivacy are unchanged (EN + ES). Pure-Swift signal-based traps stay a
documented maintainer / third-party-SDK option (the same posture as Android's Crashlytics
note), and the handler re-checks the live opt-in at crash time so opting out stops new logs
at once. v2.0.6 also re-verifies the v1.9.x host features (Game Center, CloudKit save sync,
MFi controllers, capture / PiP, accessibility) against the unchanged v2.0.0 bridge
surface — recorded in docs/ios-v2.0.6-readiness.md.
v2.0.7 "Trim" — polish + the App Store submission floor. From 2026-04-28 Apple
requires every App Store Connect upload to be built with the iOS 26 SDK (Xcode 26) —
the build SDK, not the minimum OS. The tag-gated ios.yml now selects the newest
Xcode_26*.app on the runner before building the xcframework (non-breaking: it warns and
falls back on an older image, so the compile job still runs). v2.0.7 also reconciles the
deployment target iOS 15.0 → 17.0: the shell already uses NavigationStack (iOS 16) +
.topBarTrailing (iOS 17, unguarded at 12+ sites), so the prior 15.0 was never buildable —
17.0 matches the real API floor (guard those APIs first to target lower). PrivacyInfo
.xcprivacy was re-audited against the v2.0.6 crash reporter and needs no change (no
new data type / required-reason API — it is local-only, backup-excluded, off by default,
and UserDefaults is already declared). On-device Metal/ProMotion profiling + the Xcode-26
archive are the v2.0.9 carryover. See docs/ios-v2.0.7-readiness.md.
v2.0.8 "Harborlight" — the iOS release candidate. The final release of the iOS
finalization window stages the App Store scaffolding for v2.1.0: version-controlled App
Store Connect listing metadata under fastlane/metadata/ios/{en-US,es-ES}/ (name /
subtitle / promotional text / keywords / description / release notes / URLs + copyright),
mirroring the Android fastlane/metadata/android/ tree but namespaced under ios/ so
deliver (iOS) and supply (Android) never collide; a dormant App Store release
lane in fastlane/Fastfile that stages the build + listing and does not submit
(submit_for_review: false, automatic_release: false) and is not wired into
ios.yml — the interim channel stays TestFlight (the beta lane) until v2.1.0; and an
App-Review §4.7 self-audit (docs/ios-v2.0.8-readiness.md). Screenshots + the app
preview, real-cert signing, the listing upload, and the App-Review submission are the
maintainer / v2.0.9 / v2.1.0 closeout. This closes the v2.0.5→v2.0.8 iOS finalization
window; v2.0.9 is the joint (Android + iOS) on-device readiness pass, and v2.1.0
the joint store launch.
Explicitly NOT on the iOS bridge (post-v2.0.0 carryovers). The mobile bridge is
iNES / NES 2.0-only, so FDS disk images (.fds) and NSF music files (.nsf)
cannot be loaded — the picker + Info.plist advertise only .nes (+ .zip), and
there is correspondingly no NSF player. The native desktop 20-band EQ (the
mobile audio-depth panel ships a 5-band EQ) and the debugger's .dbg ca65/cc65
source maps are likewise deferred to the post-v2.0.0 mobile re-port. These land
when the iOS app re-ports onto the v2.0.0 "Timebase" core.
Maintainer-manual carryovers (cannot be CI-self-certified, mirroring the
Android line): an Apple Developer Program account + bundle ID; the signing secrets
(fastlane match private repo + the App Store Connect API key); app-icon /
launch-screen art; and the on-device TestFlight verification (ROM import,
save-states / rewind, MFi controller, audio + interruptions across iOS 16/17/18,
no crashes on 5-10 test ROMs, ProMotion pacing, an accurate privacy label). See
ios/README.md for the local-build + verification checklist.