Android and Apple targets: structure, lifecycle, assets and evidence
Evidence basis: source-verified at the pinned commit; tests exist (not executed for this page). Claims on this page were checked by reading the CNA source at commit 3b43bd86; unless a sentence says otherwise, nothing here was built or executed. Checked by reading the Android demo project, the devices, platform, content, storage and runtime sources, the Apple CMake and workflows, and CNA’s Apple records (docs/apple-platforms.md, plans/plan_apple_m4.md) at c6dec91f and re-checked at 3b43bd86, and Sharp Runtime next @ 41b918c9. Nothing was built, run on an emulator or device, or launched on macOS/iOS for this page; the Android emulator results and the Mac mini M4 results are CNA’s own records, not re-run here.
Platform support is not one fact: a source tree can contain platform branches, cross-compile, run in an emulator, package an application, draw a frame and survive real hardware, or stop at any rung in between. This page describes where CNA's Android, macOS and iOS routes stand at this snapshot: how the Android application is put together and where the Android-specific code lives, how Game handles mobile lifecycle events, how content and saves are found on a device, what CNA’s own qualification on a physical Mac mini M4 measured, what the Apple workflows are configured to check, and which historical results are records of their own revision rather than evidence for this one. The user-level guides are Platforms: Android, iOS and tvOS, Tutorial 82 and Tutorial 109.
Target OS, platform implementation and renderer stay separate
Android, macOS and iOS are target operating systems. All three are served by the SDL3 platform implementation, which is the only one any Apple or Android route configures; iOS is not a CNA_PLATFORM value. The renderer is a third choice: with nothing selected, macOS and iOS builds get SDL_GPU, which reaches Metal through SDL’s Metal GPU driver, and Android builds get OPENGLES3, as Linux and the BSDs do (RendererIdentityDefault.cmake); iOS allows only SOFTWARE and SDL_GPU unless CNA_APPLE_ALLOW_UNVALIDATED_RENDERER=ON is set for an experiment, and SDL_GPU has never been built for iOS by a recorded run (ApplePlatform.cmake). The same file refuses any other Darwin target: tvOS, watchOS and visionOS fail configuration with a message that names the two supported Apple targets, because nothing has been configured, built or reasoned about for them.
On both mobile systems the operating system owns the process start. The game's main() translation unit must include Entrypoint.hpp, which renames it at preprocessing time: on Android SDL's Java glue locates the game by dlsym() of SDL_main, and on iOS UIKit must start before the game's entry point is called from inside a running application. This is why the SDL3 implementation reports the managedEntrypoint capability and the desktop-only implementations do not. The composition rules are on the cross-platform contract.
How the Android application is put together
The only Android build project in the tree is the Devices demo, a complete Gradle application of 39 files under demo_devices/android: the Gradle wrapper and settings, a manifest, DemodevicesActivity (an SDLActivity subclass), SDL's Java glue under org/libsdl/app, launcher resources, and two native build descriptions. app/build.gradle pins compile and target SDK 35, NDK 30.0.14904198, minimum SDK 24 and the single ABI arm64-v8a. It builds through ndk-build by default and through CMake when the Gradle property BUILD_WITH_CMAKE is set. The CMake route's jni/CMakeLists.txt forces CNA_BUILD_TESTS and CNA_BUILD_EXAMPLES off and adds the CNA root with add_subdirectory, so producing the APK compiles CNA as part of the application and reuses CNA's own .sdl-prebuilt-Android-aarch64 SDL install; CNA's SDL helper forwards ANDROID_ABI, ANDROID_PLATFORM and ANDROID_STL to that SDL build and builds SDL3 as a shared library.
The experimental C ABI library also configures and builds for Android with the NDK alone: libcurl is optional there, and without it the gamer-services and relay transports refuse, as they do in a browser (CBIND-136; a commit record, not a CI job). There is no Android preset, no Android workflow and no CMake check of the API level. The details of the configure route are on configuring CNA: Android.
Android-specific production code is concentrated
Almost all Android-only code is the NDK sensor layer in modules/devices (about 3,300 lines across four sources and six headers). Each AndroidSensorBridge owns an ASensorManager, an ALooper, an event queue and its own worker std::thread, with deliberate stop and join rules so that a destroyed looper is never touched. The Motion backend aggregates six bridges (rotation vector, game rotation vector, gravity, linear acceleration, gyroscope and magnetic field), so the single thread-construction site can produce six concurrent workers for one Motion instance. The sensor mathematics (AndroidMotionMath, AndroidCompassMath, AndroidSensorOrientation) sits outside the __ANDROID__ guards so native tests exercise it on any host; only the I/O boundary is platform-gated. The module's only Android link is target_link_libraries(cna_devices PUBLIC android log) (devices CMake): libandroid.so for the sensor API and liblog.so for disable and destroy diagnostics. The sensor semantics are on device module internals.
CNA does use JNI, in one place: the SDL3 file service resolves a packaged asset name case-insensitively by obtaining the activity through SDL_GetAndroidJNIEnv() and listing directories through the Java AssetManager (Sdl3SystemServices.cpp). Graphics on Android is the default OPENGLES3 through EasyGL and SDL’s GLES context; the only renderer with an explicit Android native-window surface path is WEBGPU (WGPUSurfaceSourceAndroidNativeWindow in WebGPURenderer.cpp), and no record shows it running on Android.
Mobile lifecycle: explicit, with host tests and recorded emulator runs
The platform event contract carries four application-lifecycle transitions, AppLifecycleKind::WillEnterBackground, DidEnterForeground, LowMemory and Terminating (PlatformEvent.hpp). Game reacts to them in its event pump (Game.cpp, PollEvents):
| Event | What Game does | Why |
|---|---|---|
WillEnterBackground | marks itself suspended and sets IsActive false | iOS terminates an application that submits GPU work in the background and Android destroys the rendering surface, so the loop must stop drawing |
DidEnterForeground | clears the suspension, restarts the frame clock and ResetElapsedTime(), sets IsActive true and tells the renderer its surface was invalidated (OnSurfaceInvalidated) | the background period is not gameplay time; without the reset the first frame would run a burst of catch-up updates clamped to MaxElapsedTime |
LowMemory | logs a warning, nothing else | XNA has no hook for it; the game decides what to release |
Terminating | clears the suspension and calls Exit() | it is the last event a mobile process receives, so the loop must leave its wait and reach OnExiting |
The flag is set on every platform but acted on only where isMobilePlatform() is true: there RunLoop() parks the thread in WaitWhileSuspended() instead of ticking, and desktop builds keep the plain loop. The behaviour is a deliberate deviation from FNA, which tracks only IsActive for these events. The portable test GameTest.MobileLifecycleEventsSuspendResumeAndTerminateTheLoop covers the sequence, and the macOS CI job includes it in its filter.
The events also have to reach the pump. SDL3 sends them through SDL_SendAppEvent, which hands them to event watchers only and never queues them, so Sdl3Platform installs an event watch the first time it polls and appends the lifecycle events it collected after the queued events, each delivered once (BL-20; two host tests, one of which fails without the watch). On Android SDL blocks the main thread while the activity is paused, so both halves of a Home-and-return reach the game together when it resumes. On Android, window focus no longer drives IsActive either: SDL focuses its one window when it is created and the activity's own focus changes arrive afterwards, so FocusLost and FocusGained leave IsActive alone there and the lifecycle events decide it, as XNA did on the phone; desktop behaviour is unchanged (BL-21). CNA's commit record measures both on the Android emulator with one game, House Simulator: cold launches are active and accept touch, and Home then return logs deactivated then activated. That is still not a test in the tree, and it does not show that a game's graphics resources survive a real background transition. A useful device or emulator test would drive background, foreground, low memory and process recreation, then check saved state and the reconstruction of graphics resources.
Two phone conventions are handled in the same layer, with host tests rather than device tests. Android's back gesture delivers its key down and up between two input updates, so the SDL3 pump latches it: the next keyboard update reports Escape once, and for a game that has asked for pads the next gamepad update reports Back on player one once, the button Windows Phone games exit on (CBIND-137). And a portrait game runs in portrait: GraphicsDeviceManager resolves a SupportedOrientations of DisplayOrientation.Default from the preferred back-buffer size, creates the device in an orientation the game supports, and the SDL window re-reads the orientation hint on Android (CBIND-138).
Content and saves on a device
On Android a relative content path names a packaged asset, not a file under the working directory (which is commonly / and unreadable to an application). ContentManager.cpp handles that explicitly: under __ANDROID__, ResolveExistingAssetPath leaves a relative path alone instead of walking the working directory, and TryReadAssetBytes reads it through the platform file service's TryLoadFileIgnoringCase. That route serves the .xnb tier, both .cnb tiers (by name and by literal .cnb name, BL-19) and the base stream; the loose tier (ResolveAssetPath's .cnj and extension probing, then the image loader's std::ifstream) still tests std::filesystem::exists, which cannot see APK assets (CNA-BUG-041). The SDL3 file service tries SDL_LoadFile, then the case-insensitive AssetManager resolution described above, and then refuses to fall back to a walk of the host file system for a relative path. TitleContainer follows the same order: std::filesystem first, then the Android asset name through the same service. On Android the pinned SDL reports the APK's assets as the base path assets://, a prefix only SDL's own I/O understands, so the SDL3 file service turns it into ./ and TitleLocation.Path keeps that spelling; SDL still resolves a relative file through internal storage and then the assets. Listing a relative directory that exists only among the assets needs CNA's Android-only SDL patch sdl-86296ac8-0004-android-relative-asset-enumeration.patch, which CNA checked on the host with Android's services stubbed; CNA records that no Android build has been run with it yet. StorageDevice cannot use HOME on Android (it is not defined), so it asks Sharp Runtime's storage policy for the package's private files directory (SDL_GetAndroidInternalStoragePath(), with /data/local/tmp as a last resort) and keeps its per-game directory beneath it (StorageDevice.cpp, EnsureStorageRoot).
These routes are mostly source paths: they need a real Activity and a packaged AssetManager, and no test in the tree loads content through ContentManager inside an APK. The one device-side observation is CNA's commit record of House Simulator's APK loading all of its .cnb content on the emulator (BL-19). On the desktop, including macOS, a relative content root is resolved under TitleLocation.Path first, as XNA opens content through TitleContainer: when <title path>/<root> is an existing directory it is used, otherwise the root is taken as given, relative to the working directory, and then through the platform file service (CBIND-140). TitleLocation reports the platform's base path. No CNA code chooses an application bundle's Resources directory itself, and the Apple smoke applications load no content, so shipping content inside a macOS or iOS bundle is a layout the application has to arrange and test. The resolution rules themselves are on ContentManager resolution rules.
Recorded Android execution is history for its own revision
CNA's own notes record emulator work from July 2026 (devices-build.md, devices-android.md): a Gradle build of the Devices demo produced a 7.3 MB debug APK, which was installed and launched on the Medium_Phone emulator image and answered synthetic sensor values injected through the emulator console; a screen capture in the same notes shows the demo's own interface drawn by that revision's default renderer, so that earlier revision did present frames on the emulator (no capture is retained). The same notes limit the claim: the emulator's console offered no virtual rotation-vector sensor, so the Compass and Motion NDK paths were never driven by injected values, only the SDL-delivered accelerometer and gyroscope. A later note, android-graphics-limitations.md, records an NDK cross-compile that failed inside two Sharp Runtime source files before reaching any CNA graphics code; one of those failures, the use of std::chrono::clock_cast that the NDK's libc++ lacks, is guarded by a library-specific preprocessor split in Sharp Runtime next @ 41b918c9 (read, not built).
Later records come from games rather than the demo. Commits from late September and early October 2026 describe Android emulator runs of House Simulator that found the lifecycle, focus and .cnb defects above (BL-19, BL-20, BL-21) and a game main() that was not renamed to SDL_main when CNA was added as a subdirectory, because the platform definition did not reach the game (BL-13). Another found that models drew as scattered triangles on the emulator: GLES 3.0 has no base-vertex draw, EasyGL emulates it by shifting attribute pointers it reads back, and the emulator's GLES encoder answers those reads from state that does not follow the bound vertex array, so EasyGL now recognises the emulator and rebases the indices on the CPU there (CNA_EASYGL_CPU_BASE_VERTEX=1 forces this anywhere; CBIND-139).
None of this is retained as an artifact: the repository contains no APK, AAB, logcat capture, Android screenshot or machine-readable test result, and no CNA binary has a record of running on physical Android hardware. The honest wording is "recorded emulator execution", not a reproducible Android gate and not current graphics verification; no Android APK of any CNA application is published.
macOS: the Mac mini M4 qualification and the workflows
SDL_GPU, the default on macOS, maps XNA onto SDL’s GPU API and lets SDL drive Metal underneath it. Its shaders reach SDL’s Metal driver through SDL_shadercross, which CNA_SDL_GPU_SHADERCROSS (ON by default on Apple targets) fetches together with SPIRV-Cross, and its ShaderEffect route is compiled out on Apple targets because Metal cannot consume the raw SPIR-V that route produces. A quoted Apple result must therefore carry the renderer identity and the revision of the build that produced it.
CNA’s own qualification ran on a physical Mac mini M4 (Apple M4, 16 GB, macOS 27.0.1, Xcode 27, Apple clang 21) on 7 and 8 October 2026, recorded task by task in plans/plan_apple_m4.md and summarised in apple-platforms.md. At its close-out these renderer trees ran their full test suites with no failing case: SOFTWARE 11,084/11,084, OPENGL33 11,916/11,916 with compiled effects on, SDL_GPU on SDL’s Metal driver 10,962/10,962, WEBGPU on wgpu-native’s Metal driver 10,968/10,968 and FNA3D on its Metal driver 10,875/10,875; the remaining cases skip by name (a capability is absent, another renderer is active, a facility is Linux-only). Those runs were made with the console session locked, so macOS reported every window occluded and on-screen presentation was not observed. The campaign is history for its revision: SDL_GPU has not been re-qualified on Apple hardware at this snapshot, and no Intel Mac was run.
apple-ci.yml runs its macOS jobs on GitHub’s macos-26 runners and checks out the sibling branches CNA’s own sibling script chooses. Its macOS build job configures SOFTWARE, builds CnaTests and runs the target-platform, game-window, mobile-lifecycle, storage and desktop-OS suites directly rather than through CTest; it then builds a self-contained cna_macos_smoke.app, lints its Info.plist, uses otool -L to reject any dependency that still points at the build machine (the SDL prebuilt root, Homebrew or /usr/local) in the executable or the embedded frameworks, and launches it until it prints CNA_APPLE_SMOKE_OK. A second job builds SDL_GPU for macOS and runs a focused smoke set (run-sdlgpu-smoke.sh) that creates a real device on SDL’s Metal driver, draws and reads pixels back, and fails the job when the device cannot be created; it is a gate, but a smoke set, not SDL_GPU’s Apple qualification. No green hosted run of that job is recorded at this snapshot, so the numbers above are the Mac mini M4’s, not CI’s. The deployment floors (macOS 13.3, iOS 16.3) are on Platforms: macOS; a binary linked against Homebrew's FFmpeg starts only on the macOS that FFmpeg was built for unless video is configured off.
iOS: a Simulator smoke launch and a device build that is not run
The iOS route is ios.cmake with CNA_IOS_SIMULATOR choosing the SDK; SDL is linked statically, and a small Objective-C++ adapter asks UIKit to re-evaluate supported orientations when GraphicsDeviceManager changes them. The smoke application, apple_smoke.cpp, constructs a Game and a GraphicsDeviceManager, requests LandscapeLeft, runs RunOneFrame() and prints CNA_APPLE_SMOKE_OK: one pass through initialisation, events, update, draw and presentation, with no content.
The apple-ci.yml iOS matrix has four legs, SOFTWARE for an arm64 device and an arm64 simulator, and the same two for SDL_GPU as non-gating legs (SDL_GPU has never been built for iOS by a recorded run), with tests, examples and networking off (CNA_ENABLE_NET=OFF is required, because the iOS SDK ships no libcurl). It checks the archive’s LC_BUILD_VERSION platform, verifies that the final bundle exports SDL_main and carries no build-machine dynamic dependency, and for the simulator boots a device, ad-hoc signs, installs, launches with the console attached and requires the one-frame marker within a bounded interval; the SDL_GPU simulator leg also runs cna_ios_pixel_probe, which reads back a clear colour, a SpriteBatch quad and a BasicEffect triangle. No green hosted run of the matrix is recorded at this snapshot, and the probe has no recorded result with a current renderer. One platform fix from CNA’s Mac mini M4 work stays in the tree: SDL’s UIKit view-frame computation swapped a portrait window’s bounds before UIKit rotated it to a landscape-only orientation set, so a game drew into a 1346x0 view, and CNA applies an SDL patch for it to iOS builds only (sdl-86296ac8-0003-uikit-scene-window-frame.patch, one of the five CNA patches on the pinned SDL 86296ac8; SDL's main branch stopped swapping on iOS 13 and later after that pin). The device build is final-linked (Mach-O arm64, platform 2, minimum iOS 16.3, system frameworks only) and not run: no physical iPhone or iPad, signing identity or Apple account was available, so there are no device pixels, touch, audio, storage, background/resume or performance observations.
What would raise the evidence
Each of these would move one route up exactly one rung, and should say which rung it proves: an archived Android graphics build with an emulator frame and its logs; an emulator test that drives the lifecycle transitions and checks state and graphics-resource reconstruction; content loaded through ContentManager inside an APK and inside an Apple bundle; a green hosted run of the Apple workflow with retained artifacts; the SDL_GPU Apple qualification on macOS and its first iOS build; on-screen presentation and PresentInterval pacing observed on an unlocked Mac, and an SDL_GPU soak run; a run on an Intel Mac; and a result from a physical iOS device. tvOS would need a designed host, not an inferred one. The platform-wide status table is on Platforms: status by operating system.
Read in this order
Entrypoint.hpp: why mobile entry points are a build concern.Game.cpp: the lifecycle cases inPollEventsandWaitWhileSuspended.ContentManager.cppandSdl3SystemServices.cpp: the Android asset route.AndroidSensorBridge.cpp: the NDK sensor thread model.ApplePlatform.cmakeandapple-ci.yml: the Apple gates and what the workflow checks.
Related pages
The same subject is explained at several altitudes. These are the neighbouring pages at each one.
- User guide
- Platforms: Android · Platforms: iOS and tvOS · Tutorial 82: Android · Tutorial 109: macOS and iOS
- Architecture
- Runtime architecture · Platform architecture
- Internals
- Device module internals · SDL3 platform internals
- Tests and validation
- Test architecture